kiwi-code 0.0.504__tar.gz → 0.0.506__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 (141) hide show
  1. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/run_activity.py +59 -0
  4. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/cli.py +6 -0
  5. kiwi_code-0.0.506/src/kiwi_runtime/fs/edits.py +185 -0
  6. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/handler.py +10 -2
  7. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/ui.py +23 -0
  8. kiwi_code-0.0.506/src/kiwi_tui/attention.py +107 -0
  9. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/main.py +55 -10
  10. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_activity_view.py +47 -8
  11. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard.py +191 -18
  12. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_css.py +8 -5
  13. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_helpers.py +1 -0
  14. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_widgets.py +46 -1
  15. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/slash_commands.py +8 -1
  16. kiwi_code-0.0.506/src/kiwi_tui/terminal_theme.py +91 -0
  17. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/conftest.py +17 -0
  18. kiwi_code-0.0.506/tests/test_attention.py +139 -0
  19. kiwi_code-0.0.506/tests/test_reply_selection.py +69 -0
  20. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_run_activity.py +121 -5
  21. kiwi_code-0.0.506/tests/test_runtime_edit_diffs.py +130 -0
  22. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_term_dashboard_ui.py +47 -0
  23. kiwi_code-0.0.506/tests/test_terminal_theme.py +19 -0
  24. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_tui_palette.py +19 -2
  25. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/uv.lock +1 -1
  26. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/.github/workflows/publish.yml +0 -0
  27. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/.github/workflows/test.yml +0 -0
  28. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/.gitignore +0 -0
  29. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/.python-version +0 -0
  30. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/Makefile +0 -0
  31. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/README.md +0 -0
  32. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/docs/runtime_audit.html +0 -0
  33. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/docs/social_login_e2e.md +0 -0
  34. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/scripts/set_edition.py +0 -0
  35. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/scripts/ws_auth_check.py +0 -0
  36. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/common/__init__.py +0 -0
  37. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/common/banner.py +0 -0
  38. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/__init__.py +0 -0
  39. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/_edition.py +0 -0
  40. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/auth.py +0 -0
  41. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/autocode.py +0 -0
  42. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/checkpoints.py +0 -0
  43. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/cli.py +0 -0
  44. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/client.py +0 -0
  45. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/commands.py +0 -0
  46. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/edition.py +0 -0
  47. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/human_questions.py +0 -0
  48. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/io_threads.py +0 -0
  49. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/logger.py +0 -0
  50. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/models.py +0 -0
  51. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/oauth.py +0 -0
  52. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/runtime_manager.py +0 -0
  53. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/server.py +0 -0
  54. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/subagents.py +0 -0
  55. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/terminal_mode.py +0 -0
  56. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/__init__.py +0 -0
  57. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/__main__.py +0 -0
  58. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/auth.py +0 -0
  59. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/checkpoints.py +0 -0
  60. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/commands.py +0 -0
  61. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/constants.py +0 -0
  62. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/__init__.py +0 -0
  63. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/batch.py +0 -0
  64. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/codelens.py +0 -0
  65. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/context.py +0 -0
  66. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/paths.py +0 -0
  67. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/read.py +0 -0
  68. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/search.py +0 -0
  69. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/text.py +0 -0
  70. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/upload.py +0 -0
  71. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/write.py +0 -0
  72. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/handlers.py +0 -0
  73. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/link.py +0 -0
  74. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/main.py +0 -0
  75. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/restricted.py +0 -0
  76. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/sessions.py +0 -0
  77. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/state.py +0 -0
  78. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/tui_bridge.py +0 -0
  79. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/uploads.py +0 -0
  80. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/__init__.py +0 -0
  81. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/inline_file_picker.py +0 -0
  82. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/latex_unicode.py +0 -0
  83. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/random_words.py +0 -0
  84. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/role_mode.py +0 -0
  85. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/runtime_agent.py +0 -0
  86. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/__init__.py +0 -0
  87. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/attach_content.py +0 -0
  88. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/command_result.py +0 -0
  89. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/detach_files.py +0 -0
  90. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/file_browser.py +0 -0
  91. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/help.py +0 -0
  92. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/id_picker.py +0 -0
  93. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/login.py +0 -0
  94. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  95. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  96. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/slash_picker.py +0 -0
  97. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  98. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_status_line.py +0 -0
  99. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
  100. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/status_words.py +0 -0
  101. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/term_app.py +0 -0
  102. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/ui_settings.py +0 -0
  103. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/widgets.py +0 -0
  104. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/worktrees.py +0 -0
  105. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/test_hello.py +0 -0
  106. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/__init__.py +0 -0
  107. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_account_server.py +0 -0
  108. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_autocode_actions.py +0 -0
  109. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_batch_fs_tool.py +0 -0
  110. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_checkpoints.py +0 -0
  111. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_cli_help.py +0 -0
  112. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_command_tips.py +0 -0
  113. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_copy_path_fs_tool.py +0 -0
  114. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_fs_edit_fidelity.py +0 -0
  115. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_human_questions.py +0 -0
  116. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_imports.py +0 -0
  117. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_io_threads.py +0 -0
  118. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_login_social.py +0 -0
  119. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_math_and_tables.py +0 -0
  120. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_oauth.py +0 -0
  121. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_read_file_streaming.py +0 -0
  122. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_reexec_kiwi.py +0 -0
  123. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_role_mode_injection.py +0 -0
  124. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_run_steering.py +0 -0
  125. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_cleanup_per_account.py +0 -0
  126. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_connection.py +0 -0
  127. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_fixes.py +0 -0
  128. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_imports_on_windows.py +0 -0
  129. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_log_trimming.py +0 -0
  130. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_social_login.py +0 -0
  131. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_search_in_files_fs_tool.py +0 -0
  132. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_slash_commands.py +0 -0
  133. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_stats_command.py +0 -0
  134. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_status_line.py +0 -0
  135. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_subagents.py +0 -0
  136. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_terminal_mode.py +0 -0
  137. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_toasts.py +0 -0
  138. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_tokens.py +0 -0
  139. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_tui_interactive_runtime.py +0 -0
  140. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_unified_diff.py +0 -0
  141. {kiwi_code-0.0.504 → kiwi_code-0.0.506}/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.506
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.506"
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))
@@ -0,0 +1,107 @@
1
+ """Calling the user back when the agent needs them: a question waiting for an answer, or a
2
+ run that has finished.
3
+
4
+ While kiwi's window has focus the user is there, and the screen itself shows it. When they
5
+ are elsewhere (the terminal tells kiwi when its window loses focus), kiwi:
6
+
7
+ - shows a desktop notification, through the terminal (OSC 777 / 9 / 99), so it also works
8
+ over ssh; a terminal with none of them gets `notify-send` / `osascript` instead, when
9
+ kiwi runs on the user's own machine;
10
+ - rings the terminal bell (a sound, and a mark on the tab or dock icon in most terminals);
11
+ - puts a mark in the tab title, until the window has focus again.
12
+
13
+ `/notify on | quiet | off` chooses: quiet leaves out the bell; off does none of it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ from collections.abc import Mapping
23
+
24
+ MODES = ("on", "quiet", "off")
25
+ DEFAULT_MODE = "on"
26
+ #: The tab title kiwi starts with; set by main once it knows the folder.
27
+ BASE_TITLE = "kiwi"
28
+
29
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f]+")
30
+
31
+
32
+ def mode_from(settings: Mapping) -> str:
33
+ mode = str(settings.get("notify") or DEFAULT_MODE).lower()
34
+ return mode if mode in MODES else DEFAULT_MODE
35
+
36
+
37
+ def _clean(text: str, limit: int = 160) -> str:
38
+ """One line, no control characters (they could end the sequence), and short."""
39
+ text = " ".join(_CONTROL.sub(" ", str(text or "")).split())
40
+ return text if len(text) <= limit else text[: limit - 1] + "…"
41
+
42
+
43
+ def notification_sequence(title: str, body: str, env: Mapping[str, str] = os.environ) -> str:
44
+ """The escape sequence that makes this terminal show a desktop notification; "" for a
45
+ terminal not known to have one (an unknown one would print it, or ignore it)."""
46
+ title, body = _clean(title, 60), _clean(body)
47
+ program = env.get("TERM_PROGRAM", "").lower()
48
+ term = env.get("TERM", "").lower()
49
+ if env.get("TMUX") or term.startswith(("screen", "tmux")):
50
+ return "" # tmux keeps these to itself unless told to pass them on
51
+ if env.get("KITTY_WINDOW_ID") or "kitty" in term:
52
+ return f"\x1b]99;;{title}: {body}\x1b\\"
53
+ if program in ("ghostty", "wezterm") or "ghostty" in term:
54
+ # `;` separates the fields of OSC 777.
55
+ return f"\x1b]777;notify;{title.replace(';', ',')};{body.replace(';', ',')}\x07"
56
+ if program == "iterm.app":
57
+ return f"\x1b]9;{title}: {body}\x07"
58
+ return ""
59
+
60
+
61
+ def local_notification_command(
62
+ title: str, body: str, env: Mapping[str, str] = os.environ, platform: str | None = None
63
+ ) -> list[str] | None:
64
+ """A command that shows the notification on this machine, for a terminal that cannot;
65
+ None over ssh (it would show on the wrong machine) or when there is none."""
66
+ if env.get("SSH_CONNECTION") or env.get("SSH_TTY"):
67
+ return None
68
+ platform = platform or sys.platform
69
+ title, body = _clean(title, 60), _clean(body)
70
+ if platform == "darwin":
71
+ def quoted(s: str) -> str:
72
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
73
+
74
+ return ["osascript", "-e", f"display notification {quoted(body)} with title {quoted(title)}"]
75
+ if platform.startswith("linux") and shutil.which("notify-send"):
76
+ return ["notify-send", "--app-name=Kiwi", title, body]
77
+ return None
78
+
79
+
80
+ def title_sequence(title: str) -> str:
81
+ return f"\x1b]2;{_clean(title, 120)}\x07"
82
+
83
+
84
+ def call_out(write, *, mode: str, title: str, body: str, tab: str, env: Mapping[str, str] = os.environ) -> None:
85
+ """Notify, ring and mark the tab, as `mode` allows. `write` sends text to the terminal."""
86
+ if mode == "off":
87
+ return
88
+ out = title_sequence(tab)
89
+ if mode == "on":
90
+ out += "\a"
91
+ sequence = notification_sequence(title, body, env)
92
+ write(out + sequence)
93
+ if not sequence:
94
+ command = local_notification_command(title, body, env)
95
+ if command:
96
+ launch(command)
97
+
98
+
99
+ def launch(command: list[str]) -> None:
100
+ """Start `command` and leave it: kiwi does not wait for the notification."""
101
+ try:
102
+ subprocess.Popen(
103
+ command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.DEVNULL, start_new_session=True,
105
+ )
106
+ except OSError:
107
+ pass
@@ -43,6 +43,7 @@ from kiwi_tui.runtime_agent import (
43
43
  )
44
44
 
45
45
  from kiwi_tui.ui_settings import load_settings as load_ui_settings, update_settings as update_ui_settings
46
+ from kiwi_tui import attention
46
47
 
47
48
 
48
49
 
@@ -309,9 +310,20 @@ class AutobotsTUI(App):
309
310
 
310
311
 
311
312
  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"
313
+ #: What a new install starts with, and `/theme reset` returns to: the terminal's own
314
+ #: look (see `initial_theme`). `DEFAULT_THEME` stays Kiwi's own palette, and the fallback
315
+ #: when a theme cannot be applied.
316
+ INITIAL_THEME = "terminal"
317
+
318
+ def initial_theme(self) -> str:
319
+ """The theme that follows the terminal: its background and colours, light or dark
320
+ as the terminal is. Asked once, before the TUI takes over the terminal."""
321
+ theme = getattr(self, "_terminal_theme", None)
322
+ if theme is None:
323
+ from kiwi_tui.terminal_theme import terminal_theme
324
+
325
+ theme = self._terminal_theme = terminal_theme()
326
+ return theme
315
327
 
316
328
  def _register_kiwi_theme(self) -> None:
317
329
  """Register Kiwi's default theme with Textual's theme system."""
@@ -347,17 +359,17 @@ class AutobotsTUI(App):
347
359
  settings = load_ui_settings()
348
360
  stored = settings.get("theme") if isinstance(settings, dict) else None
349
361
  if isinstance(stored, str) and stored.strip():
350
- return stored.strip()
362
+ return self.initial_theme() if stored.strip().lower() == "terminal" else stored.strip()
351
363
 
352
364
  t = str(getattr(self.config, "theme", "") or "").strip()
353
365
  tl = t.lower()
354
- if tl in {"dark", "default"}:
355
- return self.INITIAL_THEME
366
+ if tl in {"dark", "default", "terminal", ""}:
367
+ return self.initial_theme()
356
368
  if tl == "black":
357
369
  return self.DEFAULT_THEME
358
370
  if tl in {"light"}:
359
371
  return "textual-light"
360
- return t or self.INITIAL_THEME
372
+ return t
361
373
 
362
374
  def set_theme_preference(self, theme_name: str, *, persist: bool = True) -> tuple[bool, str]:
363
375
  """Set the active theme and (optionally) persist it to ~/.kiwi/settings.json."""
@@ -366,8 +378,12 @@ class AutobotsTUI(App):
366
378
  return False, "Usage: /theme set <name>"
367
379
 
368
380
  norm = raw
381
+ saved = None
369
382
  nl = raw.lower()
370
- if nl in {"dark", "black", "default"}:
383
+ if nl == "terminal":
384
+ # Follow the terminal: remembered as such, so the next start asks it again.
385
+ norm, saved = self.initial_theme(), "terminal"
386
+ elif nl in {"dark", "black", "default"}:
371
387
  norm = self.DEFAULT_THEME
372
388
  elif nl in {"light"}:
373
389
  norm = "textual-light"
@@ -383,9 +399,11 @@ class AutobotsTUI(App):
383
399
 
384
400
  if persist:
385
401
  try:
386
- update_ui_settings({"theme": norm})
402
+ update_ui_settings({"theme": saved or norm})
387
403
  except Exception:
388
404
  pass
405
+ if saved == "terminal":
406
+ return True, f"Theme follows your terminal ({norm})"
389
407
  return True, f"Theme set to: {norm}"
390
408
  def _is_dark_theme(self) -> bool:
391
409
  """Best-effort detection of whether the active theme is dark."""
@@ -885,6 +903,32 @@ class AutobotsTUI(App):
885
903
  #: The most toasts shown at once; older ones give way.
886
904
  MAX_TOASTS = 3
887
905
 
906
+ def call_for_attention(self, *, title: str, body: str, tab: str) -> bool:
907
+ """Call the user back (notification, bell, tab title) when kiwi's window is not
908
+ in focus: when it is, they are here. Whether it did."""
909
+ if self.app_focus:
910
+ return False
911
+ try:
912
+ mode = attention.mode_from(load_ui_settings())
913
+ attention.call_out(self._write_to_terminal, mode=mode, title=title, body=body, tab=tab)
914
+ except Exception as e:
915
+ logger.debug(f"Calling for attention failed: {e}")
916
+ return False
917
+ self._tab_marked = mode != "off"
918
+ return True
919
+
920
+ def _write_to_terminal(self, text: str) -> None:
921
+ driver = self._driver
922
+ if driver is not None and text:
923
+ driver.write(text)
924
+ driver.flush()
925
+
926
+ def on_app_focus(self, _event) -> None:
927
+ # Back at kiwi: the mark on the tab has done its job.
928
+ if getattr(self, "_tab_marked", False):
929
+ self._tab_marked = False
930
+ self._write_to_terminal(attention.title_sequence(attention.BASE_TITLE))
931
+
888
932
  def _on_notify(self, event) -> None:
889
933
  make_room_for_toast(self._notifications, event.notification, self.MAX_TOASTS)
890
934
  super()._on_notify(event)
@@ -1280,7 +1324,8 @@ def main() -> int:
1280
1324
  if sys.stdout.isatty():
1281
1325
  # Strip C0 control chars and DEL to prevent terminal injection via crafted dir names.
1282
1326
  safe_dir = "".join(ch for ch in display_dir if ch >= " " and ch != "\x7f")
1283
- sys.stdout.write(f"\033]0;kiwi ({safe_dir})\007")
1327
+ attention.BASE_TITLE = f"kiwi ({safe_dir})"
1328
+ sys.stdout.write(f"\033]0;{attention.BASE_TITLE}\007")
1284
1329
  sys.stdout.flush()
1285
1330
 
1286
1331
  runtime_args = _build_runtime_args(args)