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.
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/PKG-INFO +1 -1
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/pyproject.toml +1 -1
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/edition.py +8 -4
- kiwi_code-0.0.507/src/kiwi_tui/attention.py +107 -0
- kiwi_code-0.0.507/src/kiwi_tui/autoupdate.py +216 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/main.py +37 -1
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_activity_view.py +5 -5
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard.py +187 -27
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_css.py +8 -5
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_helpers.py +2 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/slash_commands.py +12 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/conftest.py +11 -0
- kiwi_code-0.0.507/tests/test_attention.py +172 -0
- kiwi_code-0.0.507/tests/test_autoupdate.py +223 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_run_activity.py +40 -5
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_term_dashboard_ui.py +47 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/uv.lock +1 -1
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/.gitignore +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/.python-version +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/Makefile +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/README.md +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/docs/runtime_audit.html +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/docs/social_login_e2e.md +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/scripts/set_edition.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/scripts/ws_auth_check.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/common/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/common/banner.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/_edition.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/autocode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/human_questions.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/io_threads.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/oauth.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/run_activity.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/subagents.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/auth.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/checkpoints.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/cli.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/commands.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/constants.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/batch.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/codelens.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/context.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/edits.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/handler.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/paths.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/read.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/search.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/text.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/upload.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/fs/write.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/handlers.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/link.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/restricted.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/sessions.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/state.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/tui_bridge.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/ui.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_runtime/uploads.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/latex_unicode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/role_mode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_status_line.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/terminal_theme.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/test_hello.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_account_server.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_autocode_actions.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_command_tips.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_fs_edit_fidelity.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_human_questions.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_io_threads.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_login_social.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_math_and_tables.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_oauth.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_reply_selection.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_role_mode_injection.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_run_steering.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_cleanup_per_account.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_connection.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_edit_diffs.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_fixes.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_imports_on_windows.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_runtime_social_login.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_stats_command.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_status_line.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_subagents.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_terminal_theme.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_toasts.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.507}/tests/test_worktrees.py +0 -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
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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
|
-
|
|
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:
|
|
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 ·
|
|
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"{
|
|
325
|
-
out.append(f"
|
|
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
|