kiwi-code 0.0.505__tar.gz → 0.0.507__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 (143) hide show
  1. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/edition.py +8 -4
  4. kiwi_code-0.0.507/src/kiwi_tui/attention.py +107 -0
  5. kiwi_code-0.0.507/src/kiwi_tui/autoupdate.py +216 -0
  6. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/main.py +37 -1
  7. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_activity_view.py +5 -5
  8. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard.py +187 -27
  9. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_css.py +8 -5
  10. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_helpers.py +2 -0
  11. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/slash_commands.py +12 -0
  12. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/conftest.py +11 -0
  13. kiwi_code-0.0.507/tests/test_attention.py +172 -0
  14. kiwi_code-0.0.507/tests/test_autoupdate.py +223 -0
  15. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_run_activity.py +40 -5
  16. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_term_dashboard_ui.py +47 -0
  17. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/uv.lock +1 -1
  18. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/.github/workflows/publish.yml +0 -0
  19. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/.github/workflows/test.yml +0 -0
  20. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/.gitignore +0 -0
  21. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/.python-version +0 -0
  22. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/Makefile +0 -0
  23. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/README.md +0 -0
  24. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/docs/runtime_audit.html +0 -0
  25. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/docs/social_login_e2e.md +0 -0
  26. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/scripts/set_edition.py +0 -0
  27. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/scripts/ws_auth_check.py +0 -0
  28. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/common/__init__.py +0 -0
  29. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/common/banner.py +0 -0
  30. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/__init__.py +0 -0
  31. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/_edition.py +0 -0
  32. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/auth.py +0 -0
  33. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/autocode.py +0 -0
  34. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/checkpoints.py +0 -0
  35. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/cli.py +0 -0
  36. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/client.py +0 -0
  37. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/commands.py +0 -0
  38. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/human_questions.py +0 -0
  39. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/io_threads.py +0 -0
  40. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/logger.py +0 -0
  41. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/models.py +0 -0
  42. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/oauth.py +0 -0
  43. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/run_activity.py +0 -0
  44. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/runtime_manager.py +0 -0
  45. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/server.py +0 -0
  46. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/subagents.py +0 -0
  47. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/terminal_mode.py +0 -0
  48. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/__init__.py +0 -0
  49. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/__main__.py +0 -0
  50. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/auth.py +0 -0
  51. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/checkpoints.py +0 -0
  52. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/cli.py +0 -0
  53. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/commands.py +0 -0
  54. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/constants.py +0 -0
  55. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/__init__.py +0 -0
  56. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/batch.py +0 -0
  57. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/codelens.py +0 -0
  58. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/context.py +0 -0
  59. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/edits.py +0 -0
  60. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/handler.py +0 -0
  61. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/paths.py +0 -0
  62. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/read.py +0 -0
  63. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/search.py +0 -0
  64. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/text.py +0 -0
  65. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/upload.py +0 -0
  66. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/write.py +0 -0
  67. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/handlers.py +0 -0
  68. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/link.py +0 -0
  69. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/main.py +0 -0
  70. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/restricted.py +0 -0
  71. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/sessions.py +0 -0
  72. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/state.py +0 -0
  73. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/tui_bridge.py +0 -0
  74. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/ui.py +0 -0
  75. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/uploads.py +0 -0
  76. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/__init__.py +0 -0
  77. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/inline_file_picker.py +0 -0
  78. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/latex_unicode.py +0 -0
  79. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/random_words.py +0 -0
  80. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/role_mode.py +0 -0
  81. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/runtime_agent.py +0 -0
  82. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/__init__.py +0 -0
  83. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/attach_content.py +0 -0
  84. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/command_result.py +0 -0
  85. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/detach_files.py +0 -0
  86. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/file_browser.py +0 -0
  87. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/help.py +0 -0
  88. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/id_picker.py +0 -0
  89. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/login.py +0 -0
  90. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  91. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  92. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/slash_picker.py +0 -0
  93. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
  94. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  95. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_status_line.py +0 -0
  96. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
  97. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/status_words.py +0 -0
  98. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/term_app.py +0 -0
  99. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/terminal_theme.py +0 -0
  100. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/ui_settings.py +0 -0
  101. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/widgets.py +0 -0
  102. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/worktrees.py +0 -0
  103. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/test_hello.py +0 -0
  104. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/__init__.py +0 -0
  105. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_account_server.py +0 -0
  106. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_autocode_actions.py +0 -0
  107. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_batch_fs_tool.py +0 -0
  108. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_checkpoints.py +0 -0
  109. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_cli_help.py +0 -0
  110. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_command_tips.py +0 -0
  111. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_copy_path_fs_tool.py +0 -0
  112. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_fs_edit_fidelity.py +0 -0
  113. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_human_questions.py +0 -0
  114. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_imports.py +0 -0
  115. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_io_threads.py +0 -0
  116. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_login_social.py +0 -0
  117. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_math_and_tables.py +0 -0
  118. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_oauth.py +0 -0
  119. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_read_file_streaming.py +0 -0
  120. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_reexec_kiwi.py +0 -0
  121. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_reply_selection.py +0 -0
  122. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_role_mode_injection.py +0 -0
  123. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_run_steering.py +0 -0
  124. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_cleanup_per_account.py +0 -0
  125. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_connection.py +0 -0
  126. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_edit_diffs.py +0 -0
  127. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_fixes.py +0 -0
  128. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_imports_on_windows.py +0 -0
  129. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_log_trimming.py +0 -0
  130. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_social_login.py +0 -0
  131. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_search_in_files_fs_tool.py +0 -0
  132. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_slash_commands.py +0 -0
  133. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_stats_command.py +0 -0
  134. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_status_line.py +0 -0
  135. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_subagents.py +0 -0
  136. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_terminal_mode.py +0 -0
  137. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_terminal_theme.py +0 -0
  138. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_toasts.py +0 -0
  139. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_tokens.py +0 -0
  140. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_tui_interactive_runtime.py +0 -0
  141. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_tui_palette.py +0 -0
  142. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_unified_diff.py +0 -0
  143. {kiwi_code-0.0.505 → kiwi_code-0.0.507}/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.505
3
+ Version: 0.0.507
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.505"
3
+ version = "0.0.507"
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"
@@ -27,6 +27,10 @@ EDITIONS: dict[str, dict[str, str]] = {
27
27
  },
28
28
  }
29
29
 
30
+ #: The package this build was published as: kiwi-ai or kiwi-code (see the module's
31
+ #: docstring). Unlike EDITION, KIWI_EDITION does not change it: it names what is
32
+ #: installed, which is what an update must ask PyPI for and install.
33
+ BUILD_EDITION = _BUILD_EDITION
30
34
  EDITION = os.environ.get("KIWI_EDITION") or _BUILD_EDITION
31
35
  if EDITION not in EDITIONS:
32
36
  raise ValueError(f"Unknown KIWI_EDITION {EDITION!r}; expected one of {sorted(EDITIONS)}")
@@ -38,10 +42,10 @@ DEFAULT_ACCOUNT = EDITIONS[EDITION]["default_account"]
38
42
 
39
43
 
40
44
  def _installed_version() -> str:
41
- # Ask whichever distribution ships kiwi_cli (kiwi-ai or kiwi-code), so the
42
- # version is right regardless of KIWI_EDITION. Editable installs don't map
43
- # kiwi_cli to a distribution, so also try each edition's package name.
44
- for dist in [*metadata.packages_distributions().get("kiwi_cli", []), *EDITIONS]:
45
+ # The package this build was published as comes first: when kiwi-ai and kiwi-code
46
+ # are both installed in one environment, both ship kiwi_cli. Then whichever
47
+ # distribution ships kiwi_cli, and each edition's name (a local checkout).
48
+ for dist in [BUILD_EDITION, *metadata.packages_distributions().get("kiwi_cli", []), *EDITIONS]:
45
49
  try:
46
50
  return metadata.version(dist)
47
51
  except metadata.PackageNotFoundError:
@@ -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
@@ -0,0 +1,216 @@
1
+ """Keeping kiwi up to date on its own.
2
+
3
+ While kiwi runs, the dashboard asks PyPI for the latest release (see
4
+ `TermDashboardScreen._check_latest_version`) and remembers it here, in
5
+ `~/.kiwi/update.json`. At the next start, before the TUI opens, a newer release is
6
+ installed with the tool kiwi was installed with, and the new version is started instead.
7
+ Installing at start rather than under a running session matters: kiwi imports parts of
8
+ itself as they are needed, and those must not come from another version.
9
+
10
+ Never updated on its own: an editable or local checkout, kiwi outside a virtual
11
+ environment (a system Python is the user's to manage), Windows (the running `kiwi.exe`
12
+ cannot be replaced), a start that is not interactive, or when turned off with
13
+ `/autoupdate off` or `KIWI_NO_AUTO_UPDATE=1`. Those get the command to run instead.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from dataclasses import dataclass
23
+ from importlib import metadata
24
+ from pathlib import Path
25
+
26
+ #: How long an update may take before kiwi gives up and starts the version it has.
27
+ INSTALL_TIMEOUT = 180
28
+ #: A release that would not install is tried again after this long.
29
+ RETRY_AFTER = 24 * 3600
30
+ #: Set in the new version's environment, so it can say what it was updated from (and so a
31
+ #: failed update cannot loop).
32
+ UPDATED_FROM_ENV = "KIWI_UPDATED_FROM"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Installer:
37
+ name: str # uv / pipx / pip
38
+ command: tuple[str, ...] # what updates kiwi
39
+ manual: str # the same, for the user to type
40
+
41
+
42
+ def package_name() -> str:
43
+ """The PyPI package kiwi was installed as: kiwi-ai or kiwi-code. Both are built from
44
+ this one codebase; the build bakes in which one it is (scripts/set_edition.py), and
45
+ KIWI_EDITION, which only points kiwi at the other backend, does not change it."""
46
+ from kiwi_cli.edition import BUILD_EDITION
47
+
48
+ return BUILD_EDITION
49
+
50
+
51
+ def _state_path() -> Path:
52
+ from kiwi_cli.checkpoints import kiwi_home_dir
53
+
54
+ return kiwi_home_dir() / "update.json"
55
+
56
+
57
+ def _read_all() -> dict:
58
+ try:
59
+ data = json.loads(_state_path().read_text(encoding="utf-8"))
60
+ return data if isinstance(data, dict) else {}
61
+ except (OSError, ValueError):
62
+ return {}
63
+
64
+
65
+ def _read_state() -> dict:
66
+ """This package's part: kiwi-ai and kiwi-code share ~/.kiwi, each with its own
67
+ releases and versions."""
68
+ state = _read_all().get(package_name())
69
+ return state if isinstance(state, dict) else {}
70
+
71
+
72
+ def _write_state(**patch) -> None:
73
+ try:
74
+ path = _state_path()
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ everything = _read_all()
77
+ everything[package_name()] = {**_read_state(), **patch}
78
+ tmp = path.with_suffix(".tmp")
79
+ tmp.write_text(json.dumps(everything), encoding="utf-8")
80
+ os.replace(tmp, path)
81
+ except OSError:
82
+ pass
83
+
84
+
85
+ def record_latest(version: str) -> None:
86
+ """The latest release PyPI has, for the next start to install."""
87
+ _write_state(latest=str(version), checked_at=time.time())
88
+
89
+
90
+ def newer(latest: str, current: str) -> bool:
91
+ try:
92
+ from packaging.version import Version
93
+
94
+ return Version(latest) > Version(current)
95
+ except Exception:
96
+ return False
97
+
98
+
99
+ def pending(current: str, *, now: float | None = None) -> str | None:
100
+ """A newer release found earlier and not given up on: the version to install."""
101
+ state = _read_state()
102
+ latest = str(state.get("latest") or "")
103
+ if not latest or not newer(latest, current):
104
+ return None
105
+ now = time.time() if now is None else now
106
+ if state.get("failed") == latest and now - float(state.get("failed_at") or 0) < RETRY_AFTER:
107
+ return None
108
+ return latest
109
+
110
+
111
+ def enabled(env=os.environ) -> bool:
112
+ if env.get("KIWI_NO_AUTO_UPDATE", "").strip().lower() in ("1", "true", "yes"):
113
+ return False
114
+ try:
115
+ from kiwi_tui.ui_settings import load_settings
116
+
117
+ return load_settings().get("auto_update", True) is not False
118
+ except Exception:
119
+ return True
120
+
121
+
122
+ def _editable(package: str) -> bool:
123
+ """A checkout installed in place (`pip install -e`, `uv run`): never updated from PyPI."""
124
+ try:
125
+ raw = metadata.distribution(package).read_text("direct_url.json")
126
+ except Exception:
127
+ return False
128
+ if not raw:
129
+ return False
130
+ try:
131
+ info = json.loads(raw)
132
+ except ValueError:
133
+ return False
134
+ return bool(info.get("dir_info", {}).get("editable")) or str(info.get("url", "")).startswith("file:")
135
+
136
+
137
+ def installer(package: str, *, prefix: str | None = None, base_prefix: str | None = None,
138
+ platform: str | None = None) -> Installer | None:
139
+ """How kiwi was installed, and so how to update it; None when kiwi must not update
140
+ itself (see the module's docstring)."""
141
+ prefix = sys.prefix if prefix is None else prefix
142
+ base_prefix = sys.base_prefix if base_prefix is None else base_prefix
143
+ platform = platform or sys.platform
144
+ if platform == "win32" or prefix == base_prefix or _editable(package):
145
+ return None
146
+ # Each tool leaves a file of its own in the environments it makes; the path is the
147
+ # fallback (a tool folder can be moved with UV_TOOL_DIR / PIPX_HOME).
148
+ env, parts = Path(prefix), Path(prefix).parts
149
+ if (env / "uv-receipt.toml").exists() or ("uv" in parts and "tools" in parts):
150
+ return Installer("uv", ("uv", "tool", "upgrade", package), f"uv tool upgrade {package}")
151
+ if (env / "pipx_metadata.json").exists() or ("pipx" in parts and "venvs" in parts):
152
+ return Installer("pipx", ("pipx", "upgrade", package), f"pipx upgrade {package}")
153
+ return Installer(
154
+ "pip", (sys.executable, "-m", "pip", "install", "--upgrade", "--quiet", package),
155
+ f"pip install --upgrade {package}",
156
+ )
157
+
158
+
159
+ def manual_command(package: str) -> str:
160
+ """What the user should type to update, for the notice when kiwi will not do it."""
161
+ found = None
162
+ try:
163
+ found = installer(package, platform="linux") # the right tool even on Windows
164
+ except Exception:
165
+ pass
166
+ return found.manual if found else f"pip install --upgrade {package}"
167
+
168
+
169
+ def _version_on_disk(package: str) -> str | None:
170
+ """The installed version as a new process sees it (this one has the old one cached)."""
171
+ try:
172
+ out = subprocess.run(
173
+ [sys.executable, "-c", f"import importlib.metadata as m; print(m.version({package!r}))"],
174
+ capture_output=True, text=True, timeout=30,
175
+ )
176
+ return out.stdout.strip() or None
177
+ except (OSError, subprocess.SubprocessError):
178
+ return None
179
+
180
+
181
+ def update_before_start(current: str, *, interactive: bool) -> bool:
182
+ """At start, before the TUI: install a newer release found earlier. Whether it was
183
+ installed (the caller then starts the new version)."""
184
+ if not interactive or os.environ.get(UPDATED_FROM_ENV) or not enabled():
185
+ return False
186
+ latest = pending(current)
187
+ if latest is None:
188
+ return False
189
+ package = package_name()
190
+ tool = installer(package)
191
+ if tool is None:
192
+ return False
193
+
194
+ print(f"Updating {package} v{current} → v{latest}…", flush=True)
195
+ try:
196
+ done = subprocess.run(
197
+ list(tool.command), stdin=subprocess.DEVNULL, capture_output=True, text=True,
198
+ timeout=INSTALL_TIMEOUT,
199
+ )
200
+ ok = done.returncode == 0
201
+ why = (done.stderr or done.stdout or "").strip().splitlines()[-1:] or [""]
202
+ except (OSError, subprocess.SubprocessError) as e:
203
+ ok, why = False, [str(e)]
204
+ if ok and not newer(_version_on_disk(package) or current, current):
205
+ ok, why = False, ["the new release did not install"]
206
+ if not ok:
207
+ _write_state(failed=latest, failed_at=time.time())
208
+ print(f"Could not update ({why[0]}). Starting v{current}; update with: {tool.manual}", flush=True)
209
+ return False
210
+ return True
211
+
212
+
213
+ def restart_updated(current: str) -> None:
214
+ """Start the version just installed in place of this process."""
215
+ env = {**os.environ, UPDATED_FROM_ENV: current}
216
+ os.execve(sys.executable, [sys.executable, "-m", "kiwi_tui.main", *sys.argv[1:]], env)
@@ -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
 
@@ -902,6 +903,32 @@ class AutobotsTUI(App):
902
903
  #: The most toasts shown at once; older ones give way.
903
904
  MAX_TOASTS = 3
904
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
+
905
932
  def _on_notify(self, event) -> None:
906
933
  make_room_for_toast(self._notifications, event.notification, self.MAX_TOASTS)
907
934
  super()._on_notify(event)
@@ -1297,7 +1324,8 @@ def main() -> int:
1297
1324
  if sys.stdout.isatty():
1298
1325
  # Strip C0 control chars and DEL to prevent terminal injection via crafted dir names.
1299
1326
  safe_dir = "".join(ch for ch in display_dir if ch >= " " and ch != "\x7f")
1300
- 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")
1301
1329
  sys.stdout.flush()
1302
1330
 
1303
1331
  runtime_args = _build_runtime_args(args)
@@ -1340,6 +1368,14 @@ def main() -> int:
1340
1368
  )
1341
1369
  )
1342
1370
 
1371
+ # A newer release found by an earlier session is installed now, before the TUI opens,
1372
+ # and started in place of this one.
1373
+ from kiwi_tui import autoupdate
1374
+
1375
+ interactive = sys.stdin.isatty() and sys.stdout.isatty()
1376
+ if autoupdate.update_before_start(KIWI_CODE_VERSION, interactive=interactive):
1377
+ autoupdate.restart_updated(KIWI_CODE_VERSION)
1378
+
1343
1379
  _run_term_tui(runtime_args=runtime_args)
1344
1380
  return 0
1345
1381
 
@@ -313,17 +313,17 @@ def _spaced(parts: list) -> list:
313
313
  def trace_renderable(
314
314
  lines: list[TraceLine], start: int | None, *, cyan: str, total: int, shown: int = SHOWN_TRACE
315
315
  ) -> Text:
316
- """The latest `shown` trace lines: number, time since the first event, type, what it says."""
316
+ """The latest `shown` trace lines: time since the first event, type, what it says.
317
+ Kept narrow: the trace is a column beside the conversation."""
317
318
  families = {"turn": cyan, "step": "dim", "tool": "yellow", "subagent": "magenta", "question": "blue"}
318
319
  out = Text()
319
- out.append(f"Trace · latest {min(len(lines), shown)} of {total} kept · esc or ctrl+t to hide", style="dim")
320
+ out.append(f"Trace · {min(len(lines), shown)} of {total} · esc to close", style="dim")
320
321
  for line in lines[-shown:]:
321
322
  family = line.type.split(".", 1)[0]
322
323
  secs = ((line.at - (start or line.at)) / 1000) if line.at else 0
323
324
  out.append("\n")
324
- out.append(f"{line.seq:>5} ", style="dim")
325
- out.append(f"+{secs:.2f}s".ljust(10), style="dim")
326
- out.append(f"{line.type:<18}", style=families.get(family, ""))
325
+ out.append(f"+{secs:.1f}s".ljust(8), style="dim")
326
+ out.append(f"{line.type:<17}", style=families.get(family, ""))
327
327
  what = line.what if line.count == 1 else f"×{line.count} {line.what}"
328
328
  out.append(what)
329
329
  return out