kiwi-code 0.0.506__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.506 → kiwi_code-0.0.507}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/edition.py +8 -4
  4. kiwi_code-0.0.507/src/kiwi_tui/autoupdate.py +216 -0
  5. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/main.py +8 -0
  6. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard.py +66 -13
  7. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_helpers.py +1 -0
  8. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/slash_commands.py +5 -0
  9. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_attention.py +33 -0
  10. kiwi_code-0.0.507/tests/test_autoupdate.py +223 -0
  11. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/uv.lock +1 -1
  12. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/.github/workflows/publish.yml +0 -0
  13. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/.github/workflows/test.yml +0 -0
  14. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/.gitignore +0 -0
  15. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/.python-version +0 -0
  16. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/Makefile +0 -0
  17. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/README.md +0 -0
  18. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/docs/runtime_audit.html +0 -0
  19. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/docs/social_login_e2e.md +0 -0
  20. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/scripts/set_edition.py +0 -0
  21. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/scripts/ws_auth_check.py +0 -0
  22. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/common/__init__.py +0 -0
  23. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/common/banner.py +0 -0
  24. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/__init__.py +0 -0
  25. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/_edition.py +0 -0
  26. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/auth.py +0 -0
  27. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/autocode.py +0 -0
  28. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/checkpoints.py +0 -0
  29. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/cli.py +0 -0
  30. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/client.py +0 -0
  31. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/commands.py +0 -0
  32. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/human_questions.py +0 -0
  33. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/io_threads.py +0 -0
  34. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/logger.py +0 -0
  35. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/models.py +0 -0
  36. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/oauth.py +0 -0
  37. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/run_activity.py +0 -0
  38. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/runtime_manager.py +0 -0
  39. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/server.py +0 -0
  40. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/subagents.py +0 -0
  41. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_cli/terminal_mode.py +0 -0
  42. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/__init__.py +0 -0
  43. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/__main__.py +0 -0
  44. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/auth.py +0 -0
  45. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/checkpoints.py +0 -0
  46. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/cli.py +0 -0
  47. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/commands.py +0 -0
  48. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/constants.py +0 -0
  49. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/__init__.py +0 -0
  50. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/batch.py +0 -0
  51. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/codelens.py +0 -0
  52. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/context.py +0 -0
  53. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/edits.py +0 -0
  54. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/handler.py +0 -0
  55. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/paths.py +0 -0
  56. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/read.py +0 -0
  57. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/search.py +0 -0
  58. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/text.py +0 -0
  59. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/upload.py +0 -0
  60. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/write.py +0 -0
  61. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/handlers.py +0 -0
  62. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/link.py +0 -0
  63. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/main.py +0 -0
  64. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/restricted.py +0 -0
  65. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/sessions.py +0 -0
  66. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/state.py +0 -0
  67. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/tui_bridge.py +0 -0
  68. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/ui.py +0 -0
  69. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_runtime/uploads.py +0 -0
  70. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/__init__.py +0 -0
  71. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/attention.py +0 -0
  72. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/inline_file_picker.py +0 -0
  73. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/latex_unicode.py +0 -0
  74. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/random_words.py +0 -0
  75. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/role_mode.py +0 -0
  76. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/runtime_agent.py +0 -0
  77. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/__init__.py +0 -0
  78. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/attach_content.py +0 -0
  79. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/command_result.py +0 -0
  80. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/detach_files.py +0 -0
  81. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/file_browser.py +0 -0
  82. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/help.py +0 -0
  83. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/id_picker.py +0 -0
  84. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/login.py +0 -0
  85. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  86. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  87. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/slash_picker.py +0 -0
  88. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_activity_view.py +0 -0
  89. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_css.py +0 -0
  90. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
  91. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  92. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_status_line.py +0 -0
  93. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
  94. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/status_words.py +0 -0
  95. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/term_app.py +0 -0
  96. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/terminal_theme.py +0 -0
  97. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/ui_settings.py +0 -0
  98. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/widgets.py +0 -0
  99. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/src/kiwi_tui/worktrees.py +0 -0
  100. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/test_hello.py +0 -0
  101. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/__init__.py +0 -0
  102. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/conftest.py +0 -0
  103. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_account_server.py +0 -0
  104. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_autocode_actions.py +0 -0
  105. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_batch_fs_tool.py +0 -0
  106. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_checkpoints.py +0 -0
  107. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_cli_help.py +0 -0
  108. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_command_tips.py +0 -0
  109. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_copy_path_fs_tool.py +0 -0
  110. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_fs_edit_fidelity.py +0 -0
  111. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_human_questions.py +0 -0
  112. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_imports.py +0 -0
  113. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_io_threads.py +0 -0
  114. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_login_social.py +0 -0
  115. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_math_and_tables.py +0 -0
  116. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_oauth.py +0 -0
  117. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_read_file_streaming.py +0 -0
  118. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_reexec_kiwi.py +0 -0
  119. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_reply_selection.py +0 -0
  120. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_role_mode_injection.py +0 -0
  121. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_run_activity.py +0 -0
  122. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_run_steering.py +0 -0
  123. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_runtime_cleanup_per_account.py +0 -0
  124. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_runtime_connection.py +0 -0
  125. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_runtime_edit_diffs.py +0 -0
  126. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_runtime_fixes.py +0 -0
  127. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_runtime_imports_on_windows.py +0 -0
  128. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_runtime_log_trimming.py +0 -0
  129. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_runtime_social_login.py +0 -0
  130. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_search_in_files_fs_tool.py +0 -0
  131. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_slash_commands.py +0 -0
  132. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_stats_command.py +0 -0
  133. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_status_line.py +0 -0
  134. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_subagents.py +0 -0
  135. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_term_dashboard_ui.py +0 -0
  136. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_terminal_mode.py +0 -0
  137. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_terminal_theme.py +0 -0
  138. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_toasts.py +0 -0
  139. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_tokens.py +0 -0
  140. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_tui_interactive_runtime.py +0 -0
  141. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_tui_palette.py +0 -0
  142. {kiwi_code-0.0.506 → kiwi_code-0.0.507}/tests/test_unified_diff.py +0 -0
  143. {kiwi_code-0.0.506 → 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.506
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.506"
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,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)
@@ -1368,6 +1368,14 @@ def main() -> int:
1368
1368
  )
1369
1369
  )
1370
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
+
1371
1379
  _run_term_tui(runtime_args=runtime_args)
1372
1380
  return 0
1373
1381
 
@@ -40,7 +40,6 @@ from kiwi_tui.screens.file_browser import FileBrowserScreen
40
40
  from kiwi_tui.screens.help import HelpScreen
41
41
  from kiwi_tui.screens.command_result import CommandResultScreen
42
42
  from kiwi_cli.autocode import AutocodeActions, fetch_autocode_actions, is_autocode_name
43
- from kiwi_cli.edition import EDITION
44
43
  from kiwi_tui import __version__ as KIWI_CODE_VERSION
45
44
 
46
45
  from common.banner import LOGO, TEXT
@@ -570,22 +569,36 @@ class TermDashboardScreen(Screen):
570
569
 
571
570
  # ── Dynamic tip ──────────────────────────────────────────
572
571
  async def _check_latest_version(self) -> None:
573
- """Background check: compare installed version against PyPI latest."""
572
+ """Background check: compare installed version against PyPI latest. A newer one is
573
+ remembered, for the next start to install (kiwi_tui.autoupdate)."""
574
+ import os
575
+
574
576
  import httpx
575
- from packaging.version import Version
577
+
578
+ from kiwi_tui import autoupdate
579
+
580
+ updated_from = os.environ.get(autoupdate.UPDATED_FROM_ENV)
581
+ if updated_from:
582
+ self._upgrade_tip = f"Updated: v{updated_from} → v{KIWI_CODE_VERSION}."
583
+ self._refresh_tip()
584
+ # The package installed (kiwi-ai or kiwi-code), not the backend kiwi was pointed
585
+ # at: they are released separately, each with its own versions.
586
+ package = autoupdate.package_name()
576
587
  try:
577
588
  async with httpx.AsyncClient(timeout=5) as client:
578
- resp = await client.get(f"https://pypi.org/pypi/{EDITION}/json")
589
+ resp = await client.get(f"https://pypi.org/pypi/{package}/json")
579
590
  if resp.status_code != 200:
580
591
  return
581
592
  latest = resp.json()["info"]["version"]
582
- if Version(latest) > Version(KIWI_CODE_VERSION):
583
- self._upgrade_tip = (
584
- f"A new version is available! "
585
- f"v{KIWI_CODE_VERSION} → v{latest}. "
586
- f"Run: pip install --upgrade {EDITION}"
587
- )
588
- self._refresh_tip()
593
+ if not autoupdate.newer(latest, KIWI_CODE_VERSION):
594
+ return
595
+ autoupdate.record_latest(latest)
596
+ if autoupdate.enabled() and autoupdate.installer(package) is not None:
597
+ how = "kiwi updates itself at the next start."
598
+ else:
599
+ how = f"Run: {autoupdate.manual_command(package)}"
600
+ self._upgrade_tip = f"A new version is available! v{KIWI_CODE_VERSION} → v{latest}. {how}"
601
+ self._refresh_tip()
589
602
  except Exception:
590
603
  pass
591
604
 
@@ -2750,8 +2763,9 @@ class TermDashboardScreen(Screen):
2750
2763
  _do()
2751
2764
 
2752
2765
  def _call_for_attention(self, kind: str, *, answer: object = "") -> None:
2753
- """The run waits for the user's answer, or is done: call them back if they are
2754
- away from kiwi (notification, bell, tab title; see kiwi_tui.attention)."""
2766
+ """The run waits for the user's answer, is done, or has failed: call them back if
2767
+ they are away from kiwi (notification, bell, tab title; see kiwi_tui.attention).
2768
+ `answer` is the reply, or the error of a failed run."""
2755
2769
  if kind == "question":
2756
2770
  from kiwi_cli.human_questions import PENDING
2757
2771
 
@@ -2762,6 +2776,10 @@ class TermDashboardScreen(Screen):
2762
2776
  if len(pending) > 1:
2763
2777
  body = f"{body} (+{len(pending) - 1} more)"
2764
2778
  title, tab = "Kiwi needs your answer", "● kiwi – waiting for you"
2779
+ elif kind == "failed":
2780
+ lines = str(answer or "").strip().splitlines()
2781
+ body = lines[0].strip() if lines else "The run failed."
2782
+ title, tab = "Kiwi run failed", "✗ kiwi – failed"
2765
2783
  else:
2766
2784
  lines = str(answer or "").strip().splitlines()
2767
2785
  body = lines[0].lstrip("#*> ").strip() if lines else "The run has finished."
@@ -3331,6 +3349,40 @@ class TermDashboardScreen(Screen):
3331
3349
  self._show_inline_cmd_result(command, token_usage_renderable(usage, cyan=cyan))
3332
3350
  return
3333
3351
 
3352
+ if cmd == "/autoupdate":
3353
+ import os
3354
+
3355
+ from kiwi_tui import autoupdate
3356
+ from kiwi_tui.ui_settings import update_settings
3357
+
3358
+ choice = args[0].lower() if args else ""
3359
+ if choice in ("on", "off"):
3360
+ update_settings({"auto_update": choice == "on"})
3361
+ toast("Auto-update on: a new release is installed when kiwi starts."
3362
+ if choice == "on" else "Auto-update off.", title="/autoupdate")
3363
+ return
3364
+ package = autoupdate.package_name()
3365
+ tool = autoupdate.installer(package)
3366
+ if os.environ.get("KIWI_NO_AUTO_UPDATE"):
3367
+ state = "off (KIWI_NO_AUTO_UPDATE is set)"
3368
+ elif not autoupdate.enabled():
3369
+ state = "off"
3370
+ elif tool is None:
3371
+ state = "not possible for this install (a local checkout, a system Python or Windows)"
3372
+ else:
3373
+ state = f"on (with {tool.name})"
3374
+ show("\n".join([
3375
+ f"Auto-update: {state}",
3376
+ f"Installed: {package} v{KIWI_CODE_VERSION}",
3377
+ "",
3378
+ "A newer release found while kiwi runs is installed at the next start.",
3379
+ "",
3380
+ " /autoupdate on update at start (default)",
3381
+ " /autoupdate off never; kiwi only says a new version is out",
3382
+ f" Update by hand: {autoupdate.manual_command(package)}",
3383
+ ]))
3384
+ return
3385
+
3334
3386
  if cmd == "/notify":
3335
3387
  from kiwi_tui import attention
3336
3388
  from kiwi_tui.ui_settings import load_settings, update_settings
@@ -5262,6 +5314,7 @@ class TermDashboardScreen(Screen):
5262
5314
  got_final = True
5263
5315
  self._set_run_inflight(False)
5264
5316
  self._set_streaming(False)
5317
+ self._call_for_attention("failed", answer=err_msg)
5265
5318
  return True
5266
5319
  # `waiting`: the turn ended asking the user; finished until they answer.
5267
5320
  if status not in ("completed", "success", "finished", WAITING_STATUS):
@@ -31,6 +31,7 @@ STREAMING_SAFE_SLASH_PREFIXES = (
31
31
  "/help",
32
32
  "/theme",
33
33
  "/notify",
34
+ "/autoupdate",
34
35
  "/show-logs",
35
36
  "/status",
36
37
  "/cancel",
@@ -56,6 +56,11 @@ SLASH_COMMANDS: list[SlashCommand] = [
56
56
  SlashCommand("Runtime", "/show-logs", "Show local CLI (runtime) logs", "/show-logs"),
57
57
  SlashCommand("Runtime", "/runtime", "Runtime commands (currently disabled)", "/runtime"),
58
58
 
59
+ # Updates
60
+ SlashCommand("Update", "/autoupdate", "Whether kiwi updates itself, and how", "/autoupdate"),
61
+ SlashCommand("Update", "/autoupdate on", "Install new releases when kiwi starts", "/autoupdate on"),
62
+ SlashCommand("Update", "/autoupdate off", "Never update on its own", "/autoupdate off"),
63
+
59
64
  # Notifications
60
65
  SlashCommand("Notify", "/notify", "How kiwi calls you back when it needs you", "/notify"),
61
66
  SlashCommand("Notify", "/notify on", "Notification and bell when a question waits or a run finishes", "/notify on"),
@@ -126,10 +126,12 @@ async def test_a_waiting_question_names_itself_and_a_finished_run_its_answer(mon
126
126
 
127
127
  screen._call_for_attention("question")
128
128
  screen._call_for_attention("finished", answer="## Done\nAll tests pass.")
129
+ screen._call_for_attention("failed", answer="Tool call timed out\nTraceback …")
129
130
  assert calls == [
130
131
  {"title": "Kiwi needs your answer", "body": "Which database should I use? (+1 more)",
131
132
  "tab": "● kiwi – waiting for you"},
132
133
  {"title": "Kiwi finished", "body": "Done", "tab": "✓ kiwi – done"},
134
+ {"title": "Kiwi run failed", "body": "Tool call timed out", "tab": "✗ kiwi – failed"},
133
135
  ]
134
136
 
135
137
  # In kiwi already: a question is pointed out on the screen instead.
@@ -137,3 +139,34 @@ async def test_a_waiting_question_names_itself_and_a_finished_run_its_answer(mon
137
139
  screen._call_for_attention("question")
138
140
  await pilot.pause(0.05)
139
141
  assert any("waiting for your answer" in str(n.message) for n in app._notifications)
142
+
143
+
144
+ class _FailedRunClient:
145
+ def get_action_result(self, run_id):
146
+ return True, {"id": run_id, "status": "failed", "message": "Tool call timed out\nTraceback …"}, ""
147
+
148
+ def get_run_activity(self, run_id):
149
+ return 200, []
150
+
151
+ async def stream_action_result(self, run_id, on_message, **kwargs):
152
+ import asyncio
153
+
154
+ on_message({"type": "turn.started", "src": "w", "seq": 1, "at": 1, "name": "agent"})
155
+ on_message({"type": "turn.finished", "src": "w", "seq": 2, "at": 2, "ok": False, "error": "failed"})
156
+ await asyncio.sleep(3600)
157
+
158
+
159
+ async def test_a_run_that_fails_while_followed_calls_the_user_back() -> None:
160
+ import asyncio
161
+
162
+ from tests.test_term_dashboard_ui import _TermDashboardTestApp
163
+
164
+ app = _TermDashboardTestApp()
165
+ async with app.run_test(size=(100, 30)) as pilot:
166
+ await pilot.pause(0.05)
167
+ calls: list[dict] = []
168
+ app.call_for_attention = lambda **kw: calls.append(kw) or True
169
+ app.screen._set_run_inflight(True)
170
+ await asyncio.wait_for(app.screen._consume_sse("run-1", _FailedRunClient()), timeout=10)
171
+
172
+ assert calls == [{"title": "Kiwi run failed", "body": "Tool call timed out", "tab": "✗ kiwi – failed"}]