kiwi-code 0.0.505__tar.gz → 0.0.506__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/PKG-INFO +1 -1
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/pyproject.toml +1 -1
- kiwi_code-0.0.506/src/kiwi_tui/attention.py +107 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/main.py +29 -1
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_activity_view.py +5 -5
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard.py +123 -16
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_css.py +8 -5
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_helpers.py +1 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/slash_commands.py +7 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/conftest.py +11 -0
- kiwi_code-0.0.506/tests/test_attention.py +139 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_run_activity.py +40 -5
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_term_dashboard_ui.py +47 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/uv.lock +1 -1
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/.gitignore +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/.python-version +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/Makefile +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/README.md +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/docs/runtime_audit.html +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/docs/social_login_e2e.md +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/scripts/set_edition.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/scripts/ws_auth_check.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/common/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/common/banner.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/_edition.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/autocode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/edition.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/human_questions.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/io_threads.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/oauth.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/run_activity.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/subagents.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/auth.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/checkpoints.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/cli.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/commands.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/constants.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/batch.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/codelens.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/context.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/edits.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/handler.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/paths.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/read.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/search.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/text.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/upload.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/write.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/handlers.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/link.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/restricted.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/sessions.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/state.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/tui_bridge.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/ui.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/uploads.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/latex_unicode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/role_mode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_status_line.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/terminal_theme.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/test_hello.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/__init__.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_account_server.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_autocode_actions.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_command_tips.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_fs_edit_fidelity.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_human_questions.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_io_threads.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_login_social.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_math_and_tables.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_oauth.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_reply_selection.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_role_mode_injection.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_run_steering.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_cleanup_per_account.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_connection.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_edit_diffs.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_fixes.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_imports_on_windows.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_social_login.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_stats_command.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_status_line.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_subagents.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_terminal_theme.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_toasts.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_worktrees.py +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Calling the user back when the agent needs them: a question waiting for an answer, or a
|
|
2
|
+
run that has finished.
|
|
3
|
+
|
|
4
|
+
While kiwi's window has focus the user is there, and the screen itself shows it. When they
|
|
5
|
+
are elsewhere (the terminal tells kiwi when its window loses focus), kiwi:
|
|
6
|
+
|
|
7
|
+
- shows a desktop notification, through the terminal (OSC 777 / 9 / 99), so it also works
|
|
8
|
+
over ssh; a terminal with none of them gets `notify-send` / `osascript` instead, when
|
|
9
|
+
kiwi runs on the user's own machine;
|
|
10
|
+
- rings the terminal bell (a sound, and a mark on the tab or dock icon in most terminals);
|
|
11
|
+
- puts a mark in the tab title, until the window has focus again.
|
|
12
|
+
|
|
13
|
+
`/notify on | quiet | off` chooses: quiet leaves out the bell; off does none of it.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
|
|
24
|
+
MODES = ("on", "quiet", "off")
|
|
25
|
+
DEFAULT_MODE = "on"
|
|
26
|
+
#: The tab title kiwi starts with; set by main once it knows the folder.
|
|
27
|
+
BASE_TITLE = "kiwi"
|
|
28
|
+
|
|
29
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]+")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def mode_from(settings: Mapping) -> str:
|
|
33
|
+
mode = str(settings.get("notify") or DEFAULT_MODE).lower()
|
|
34
|
+
return mode if mode in MODES else DEFAULT_MODE
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _clean(text: str, limit: int = 160) -> str:
|
|
38
|
+
"""One line, no control characters (they could end the sequence), and short."""
|
|
39
|
+
text = " ".join(_CONTROL.sub(" ", str(text or "")).split())
|
|
40
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def notification_sequence(title: str, body: str, env: Mapping[str, str] = os.environ) -> str:
|
|
44
|
+
"""The escape sequence that makes this terminal show a desktop notification; "" for a
|
|
45
|
+
terminal not known to have one (an unknown one would print it, or ignore it)."""
|
|
46
|
+
title, body = _clean(title, 60), _clean(body)
|
|
47
|
+
program = env.get("TERM_PROGRAM", "").lower()
|
|
48
|
+
term = env.get("TERM", "").lower()
|
|
49
|
+
if env.get("TMUX") or term.startswith(("screen", "tmux")):
|
|
50
|
+
return "" # tmux keeps these to itself unless told to pass them on
|
|
51
|
+
if env.get("KITTY_WINDOW_ID") or "kitty" in term:
|
|
52
|
+
return f"\x1b]99;;{title}: {body}\x1b\\"
|
|
53
|
+
if program in ("ghostty", "wezterm") or "ghostty" in term:
|
|
54
|
+
# `;` separates the fields of OSC 777.
|
|
55
|
+
return f"\x1b]777;notify;{title.replace(';', ',')};{body.replace(';', ',')}\x07"
|
|
56
|
+
if program == "iterm.app":
|
|
57
|
+
return f"\x1b]9;{title}: {body}\x07"
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def local_notification_command(
|
|
62
|
+
title: str, body: str, env: Mapping[str, str] = os.environ, platform: str | None = None
|
|
63
|
+
) -> list[str] | None:
|
|
64
|
+
"""A command that shows the notification on this machine, for a terminal that cannot;
|
|
65
|
+
None over ssh (it would show on the wrong machine) or when there is none."""
|
|
66
|
+
if env.get("SSH_CONNECTION") or env.get("SSH_TTY"):
|
|
67
|
+
return None
|
|
68
|
+
platform = platform or sys.platform
|
|
69
|
+
title, body = _clean(title, 60), _clean(body)
|
|
70
|
+
if platform == "darwin":
|
|
71
|
+
def quoted(s: str) -> str:
|
|
72
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
73
|
+
|
|
74
|
+
return ["osascript", "-e", f"display notification {quoted(body)} with title {quoted(title)}"]
|
|
75
|
+
if platform.startswith("linux") and shutil.which("notify-send"):
|
|
76
|
+
return ["notify-send", "--app-name=Kiwi", title, body]
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def title_sequence(title: str) -> str:
|
|
81
|
+
return f"\x1b]2;{_clean(title, 120)}\x07"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def call_out(write, *, mode: str, title: str, body: str, tab: str, env: Mapping[str, str] = os.environ) -> None:
|
|
85
|
+
"""Notify, ring and mark the tab, as `mode` allows. `write` sends text to the terminal."""
|
|
86
|
+
if mode == "off":
|
|
87
|
+
return
|
|
88
|
+
out = title_sequence(tab)
|
|
89
|
+
if mode == "on":
|
|
90
|
+
out += "\a"
|
|
91
|
+
sequence = notification_sequence(title, body, env)
|
|
92
|
+
write(out + sequence)
|
|
93
|
+
if not sequence:
|
|
94
|
+
command = local_notification_command(title, body, env)
|
|
95
|
+
if command:
|
|
96
|
+
launch(command)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def launch(command: list[str]) -> None:
|
|
100
|
+
"""Start `command` and leave it: kiwi does not wait for the notification."""
|
|
101
|
+
try:
|
|
102
|
+
subprocess.Popen(
|
|
103
|
+
command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
104
|
+
stderr=subprocess.DEVNULL, start_new_session=True,
|
|
105
|
+
)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
@@ -43,6 +43,7 @@ from kiwi_tui.runtime_agent import (
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
from kiwi_tui.ui_settings import load_settings as load_ui_settings, update_settings as update_ui_settings
|
|
46
|
+
from kiwi_tui import attention
|
|
46
47
|
|
|
47
48
|
|
|
48
49
|
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -127,6 +127,16 @@ def _edits_path(run_id: str):
|
|
|
127
127
|
return None
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
class TranscriptScroll(VerticalScroll):
|
|
131
|
+
"""The conversation. Scrolling it up with the wheel stops following its end at once,
|
|
132
|
+
before the next streamed text would pull the view back down."""
|
|
133
|
+
|
|
134
|
+
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
|
135
|
+
if not (event.ctrl or event.shift):
|
|
136
|
+
self.screen._follow_tail = False
|
|
137
|
+
super()._on_mouse_scroll_up(event)
|
|
138
|
+
|
|
139
|
+
|
|
130
140
|
class TermDashboardScreen(Screen):
|
|
131
141
|
BINDINGS = [
|
|
132
142
|
Binding("escape", "dismiss_or_clear", "Dismiss", show=False, priority=True),
|
|
@@ -256,8 +266,11 @@ class TermDashboardScreen(Screen):
|
|
|
256
266
|
|
|
257
267
|
def compose(self) -> ComposeResult:
|
|
258
268
|
yield TermTopBar("", id="term-top-bar", markup=False)
|
|
269
|
+
# The latest activity events of the run, live, in a column at the right
|
|
270
|
+
# (ctrl+t / /trace; shown/hidden).
|
|
271
|
+
yield Static("", id="term-trace-panel", markup=False)
|
|
259
272
|
|
|
260
|
-
with
|
|
273
|
+
with TranscriptScroll(id="term-scroll-transcript"):
|
|
261
274
|
|
|
262
275
|
# Welcome header (shown at top in empty state, hidden once
|
|
263
276
|
# the user sends their first real message)
|
|
@@ -300,8 +313,6 @@ class TermDashboardScreen(Screen):
|
|
|
300
313
|
# Consistent dismissal hint for all inline slash-command panels.
|
|
301
314
|
yield Static("Press ESC to dismiss", id="term-cmd-result-dismiss", markup=False)
|
|
302
315
|
with Vertical(id="term-bottom-area"):
|
|
303
|
-
# The latest activity events of the run (ctrl+t / /trace; shown/hidden).
|
|
304
|
-
yield Static("", id="term-trace-panel", markup=False)
|
|
305
316
|
# Activity row (shown/hidden)
|
|
306
317
|
yield Static("", id="term-activity-bar", markup=False)
|
|
307
318
|
# Subagents still working while the agent waits on them (shown/hidden).
|
|
@@ -407,10 +418,14 @@ class TermDashboardScreen(Screen):
|
|
|
407
418
|
near_bottom = self._near_bottom(threshold=6)
|
|
408
419
|
at_tail = self._window_end_turn() >= len(self._turns)
|
|
409
420
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
421
|
+
# Following the end stops when the user moves up (the scrollbar too) and starts
|
|
422
|
+
# again only at the very end: new text arriving never moves the view on its own.
|
|
423
|
+
last_y = getattr(self, "_last_scroll_y", None)
|
|
424
|
+
self._last_scroll_y = min(cur_y, tgt_y)
|
|
425
|
+
if last_y is not None and min(cur_y, tgt_y) < last_y:
|
|
413
426
|
self._follow_tail = False
|
|
427
|
+
elif at_tail and self._near_bottom(threshold=1):
|
|
428
|
+
self._follow_tail = True
|
|
414
429
|
|
|
415
430
|
now = time.monotonic()
|
|
416
431
|
last_shift = float(getattr(self, "_last_window_shift_at", 0.0))
|
|
@@ -465,6 +480,7 @@ class TermDashboardScreen(Screen):
|
|
|
465
480
|
pass
|
|
466
481
|
self._refresh_logo()
|
|
467
482
|
self._refresh_input_label()
|
|
483
|
+
self._place_trace()
|
|
468
484
|
|
|
469
485
|
|
|
470
486
|
def _refresh_logo(self) -> None:
|
|
@@ -1077,8 +1093,11 @@ class TermDashboardScreen(Screen):
|
|
|
1077
1093
|
self._hide_slash_autocomplete()
|
|
1078
1094
|
return
|
|
1079
1095
|
if getattr(self, "_trace_shown", False):
|
|
1096
|
+
# Closed even while hidden by a narrow terminal, so it does not pop up later.
|
|
1097
|
+
shown = self._trace_visible()
|
|
1080
1098
|
self.action_toggle_trace()
|
|
1081
|
-
|
|
1099
|
+
if shown:
|
|
1100
|
+
return
|
|
1082
1101
|
# Priority 2: hide cmd result panel
|
|
1083
1102
|
if self._cmd_result_visible:
|
|
1084
1103
|
self._hide_cmd_result()
|
|
@@ -2730,6 +2749,32 @@ class TermDashboardScreen(Screen):
|
|
|
2730
2749
|
except Exception:
|
|
2731
2750
|
_do()
|
|
2732
2751
|
|
|
2752
|
+
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)."""
|
|
2755
|
+
if kind == "question":
|
|
2756
|
+
from kiwi_cli.human_questions import PENDING
|
|
2757
|
+
|
|
2758
|
+
pending = [q for t in self._turns for q in t.questions if q.get("status") == PENDING]
|
|
2759
|
+
if not pending:
|
|
2760
|
+
return
|
|
2761
|
+
body = pending[0].get("question") or pending[0].get("header") or "The agent is waiting for your answer."
|
|
2762
|
+
if len(pending) > 1:
|
|
2763
|
+
body = f"{body} (+{len(pending) - 1} more)"
|
|
2764
|
+
title, tab = "Kiwi needs your answer", "● kiwi – waiting for you"
|
|
2765
|
+
else:
|
|
2766
|
+
lines = str(answer or "").strip().splitlines()
|
|
2767
|
+
body = lines[0].lstrip("#*> ").strip() if lines else "The run has finished."
|
|
2768
|
+
title, tab = "Kiwi finished", "✓ kiwi – done"
|
|
2769
|
+
call = getattr(self.app, "call_for_attention", None)
|
|
2770
|
+
away = bool(call is not None and call(title=title, body=body, tab=tab))
|
|
2771
|
+
if not away and kind == "question":
|
|
2772
|
+
try:
|
|
2773
|
+
self.notify("The agent is waiting for your answer below.", title="Question",
|
|
2774
|
+
severity="information", timeout=4, markup=False)
|
|
2775
|
+
except Exception:
|
|
2776
|
+
pass
|
|
2777
|
+
|
|
2733
2778
|
def _apply_human_questions(self, questions: list[dict], *, render: bool = True) -> None:
|
|
2734
2779
|
"""Put the run's questions on the turns they were asked in.
|
|
2735
2780
|
|
|
@@ -3286,6 +3331,41 @@ class TermDashboardScreen(Screen):
|
|
|
3286
3331
|
self._show_inline_cmd_result(command, token_usage_renderable(usage, cyan=cyan))
|
|
3287
3332
|
return
|
|
3288
3333
|
|
|
3334
|
+
if cmd == "/notify":
|
|
3335
|
+
from kiwi_tui import attention
|
|
3336
|
+
from kiwi_tui.ui_settings import load_settings, update_settings
|
|
3337
|
+
|
|
3338
|
+
choice = args[0].lower() if args else ""
|
|
3339
|
+
if choice in attention.MODES:
|
|
3340
|
+
update_settings({"notify": choice})
|
|
3341
|
+
toast({
|
|
3342
|
+
"on": "Notifications on: notification and bell when kiwi needs you and you are away.",
|
|
3343
|
+
"quiet": "Notifications quiet: a notification without the bell.",
|
|
3344
|
+
"off": "Notifications off.",
|
|
3345
|
+
}[choice], title="/notify")
|
|
3346
|
+
return
|
|
3347
|
+
if choice == "test":
|
|
3348
|
+
# Shown even with kiwi in focus (and when off): this is how it looks.
|
|
3349
|
+
mode = attention.mode_from(load_settings())
|
|
3350
|
+
attention.call_out(
|
|
3351
|
+
self.app._write_to_terminal, mode="quiet" if mode == "off" else mode,
|
|
3352
|
+
title="Kiwi", body="This is how kiwi calls you back.", tab=attention.BASE_TITLE,
|
|
3353
|
+
)
|
|
3354
|
+
toast("Sent a test notification.", title="/notify")
|
|
3355
|
+
return
|
|
3356
|
+
show("\n".join([
|
|
3357
|
+
f"Notifications: {attention.mode_from(load_settings())}",
|
|
3358
|
+
"",
|
|
3359
|
+
"When a question waits for your answer, or a run finishes, while you are in",
|
|
3360
|
+
"another window: a desktop notification, the bell, and a mark on the tab.",
|
|
3361
|
+
"",
|
|
3362
|
+
" /notify on notification and bell (default)",
|
|
3363
|
+
" /notify quiet notification, no bell",
|
|
3364
|
+
" /notify off none of it",
|
|
3365
|
+
" /notify test send a test notification",
|
|
3366
|
+
]))
|
|
3367
|
+
return
|
|
3368
|
+
|
|
3289
3369
|
if cmd == "/theme":
|
|
3290
3370
|
if not args:
|
|
3291
3371
|
current = getattr(self.app, "theme", None) or "(unknown)"
|
|
@@ -5214,6 +5294,9 @@ class TermDashboardScreen(Screen):
|
|
|
5214
5294
|
self._set_streaming(False)
|
|
5215
5295
|
if status == WAITING_STATUS:
|
|
5216
5296
|
self._focus_pending_questions()
|
|
5297
|
+
self._call_for_attention("question")
|
|
5298
|
+
else:
|
|
5299
|
+
self._call_for_attention("finished", answer=extract_text(last["output"]))
|
|
5217
5300
|
return True
|
|
5218
5301
|
if status == WAITING_STATUS:
|
|
5219
5302
|
# Asked without a closing sentence: the cards are the whole reply.
|
|
@@ -5225,6 +5308,7 @@ class TermDashboardScreen(Screen):
|
|
|
5225
5308
|
self._set_run_inflight(False)
|
|
5226
5309
|
self._set_streaming(False)
|
|
5227
5310
|
self._focus_pending_questions()
|
|
5311
|
+
self._call_for_attention("question")
|
|
5228
5312
|
return True
|
|
5229
5313
|
return False
|
|
5230
5314
|
except Exception as e:
|
|
@@ -5486,7 +5570,7 @@ class TermDashboardScreen(Screen):
|
|
|
5486
5570
|
except Exception:
|
|
5487
5571
|
t = None
|
|
5488
5572
|
|
|
5489
|
-
should_follow = bool(self._follow_tail) or self._near_bottom(threshold=
|
|
5573
|
+
should_follow = bool(self._follow_tail) or self._near_bottom(threshold=1)
|
|
5490
5574
|
if should_follow:
|
|
5491
5575
|
self._follow_tail = True
|
|
5492
5576
|
self._set_window_to_tail()
|
|
@@ -5544,7 +5628,7 @@ class TermDashboardScreen(Screen):
|
|
|
5544
5628
|
except Exception:
|
|
5545
5629
|
t = None
|
|
5546
5630
|
|
|
5547
|
-
should_follow = bool(self._follow_tail) or self._near_bottom(threshold=
|
|
5631
|
+
should_follow = bool(self._follow_tail) or self._near_bottom(threshold=1)
|
|
5548
5632
|
if should_follow:
|
|
5549
5633
|
self._follow_tail = True
|
|
5550
5634
|
self._set_window_to_tail()
|
|
@@ -5574,18 +5658,41 @@ class TermDashboardScreen(Screen):
|
|
|
5574
5658
|
|
|
5575
5659
|
# ---- trace panel ----
|
|
5576
5660
|
|
|
5661
|
+
#: Narrower than this, the trace column would squeeze the conversation: it hides, and
|
|
5662
|
+
#: comes back when the terminal is wide enough again.
|
|
5663
|
+
TRACE_MIN_WIDTH = 90
|
|
5664
|
+
|
|
5577
5665
|
def action_toggle_trace(self) -> None:
|
|
5578
5666
|
"""Show or hide the latest activity events of the run being followed."""
|
|
5579
5667
|
self._trace_shown = not getattr(self, "_trace_shown", False)
|
|
5668
|
+
if self._trace_shown and not self._trace_fits():
|
|
5669
|
+
self.notify(
|
|
5670
|
+
f"The trace needs a terminal at least {self.TRACE_MIN_WIDTH} columns wide; "
|
|
5671
|
+
"it opens when the window is wider.",
|
|
5672
|
+
title="/trace", severity="warning", timeout=4, markup=False,
|
|
5673
|
+
)
|
|
5674
|
+
self._place_trace()
|
|
5675
|
+
|
|
5676
|
+
def _trace_fits(self) -> bool:
|
|
5677
|
+
return self.size.width >= self.TRACE_MIN_WIDTH
|
|
5678
|
+
|
|
5679
|
+
def _trace_visible(self) -> bool:
|
|
5680
|
+
return getattr(self, "_trace_shown", False) and self._trace_fits()
|
|
5681
|
+
|
|
5682
|
+
def _place_trace(self) -> None:
|
|
5683
|
+
"""Show the trace column when it is asked for and fits; hide it otherwise."""
|
|
5580
5684
|
try:
|
|
5581
5685
|
panel = self.query_one("#term-trace-panel", Static)
|
|
5582
|
-
panel.set_class(self._trace_shown, "visible")
|
|
5583
5686
|
except Exception:
|
|
5584
5687
|
return
|
|
5585
|
-
self.
|
|
5688
|
+
visible = self._trace_visible()
|
|
5689
|
+
if panel.has_class("visible") != visible:
|
|
5690
|
+
panel.set_class(visible, "visible")
|
|
5691
|
+
if visible:
|
|
5692
|
+
self._refresh_trace_panel(force=True)
|
|
5586
5693
|
|
|
5587
5694
|
def _refresh_trace_panel(self, *, force: bool = False) -> None:
|
|
5588
|
-
if not
|
|
5695
|
+
if not self._trace_visible():
|
|
5589
5696
|
return
|
|
5590
5697
|
view = getattr(self, "_activity_view", None)
|
|
5591
5698
|
now = time.monotonic()
|
|
@@ -5609,11 +5716,10 @@ class TermDashboardScreen(Screen):
|
|
|
5609
5716
|
cyan = self.app.get_css_variables().get("brand-cyan", "#63d8dc")
|
|
5610
5717
|
except Exception:
|
|
5611
5718
|
cyan = "#63d8dc"
|
|
5612
|
-
#
|
|
5613
|
-
shown = max(8,
|
|
5614
|
-
panel.styles.max_height = shown + 1
|
|
5719
|
+
# As many as fit in the column, under its heading.
|
|
5720
|
+
shown = max(8, self.size.height - 3)
|
|
5615
5721
|
if view is None or not view.trace:
|
|
5616
|
-
panel.update("Trace · no activity yet · esc
|
|
5722
|
+
panel.update("Trace · no activity yet · esc to close")
|
|
5617
5723
|
return
|
|
5618
5724
|
lines = list(view.trace)
|
|
5619
5725
|
panel.update(trace_renderable(lines, view.trace_start, cyan=cyan, total=len(lines), shown=shown))
|
|
@@ -6008,6 +6114,7 @@ class TermDashboardScreen(Screen):
|
|
|
6008
6114
|
def action_transcript_page_up(self) -> None:
|
|
6009
6115
|
if not self._transcript_nav_enabled():
|
|
6010
6116
|
return
|
|
6117
|
+
self._follow_tail = False
|
|
6011
6118
|
root = self._transcript_scroll()
|
|
6012
6119
|
if root is None:
|
|
6013
6120
|
return
|
|
@@ -230,13 +230,16 @@ TERM_DASHBOARD_CSS = """
|
|
|
230
230
|
/* ── Activity / pending files bars ─────────────────── */
|
|
231
231
|
#term-trace-panel {
|
|
232
232
|
display: none;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
width:
|
|
236
|
-
|
|
237
|
-
|
|
233
|
+
dock: right;
|
|
234
|
+
width: 40%;
|
|
235
|
+
min-width: 36;
|
|
236
|
+
height: 100%;
|
|
237
|
+
padding: 0 1;
|
|
238
|
+
border-left: vkey $panel-lighten-2;
|
|
238
239
|
background: $surface;
|
|
239
240
|
overflow: hidden;
|
|
241
|
+
text-wrap: nowrap;
|
|
242
|
+
text-overflow: ellipsis;
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
#term-trace-panel.visible {
|
|
@@ -56,6 +56,13 @@ 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
|
+
# Notifications
|
|
60
|
+
SlashCommand("Notify", "/notify", "How kiwi calls you back when it needs you", "/notify"),
|
|
61
|
+
SlashCommand("Notify", "/notify on", "Notification and bell when a question waits or a run finishes", "/notify on"),
|
|
62
|
+
SlashCommand("Notify", "/notify quiet", "Notification without the bell", "/notify quiet"),
|
|
63
|
+
SlashCommand("Notify", "/notify off", "No notifications", "/notify off"),
|
|
64
|
+
SlashCommand("Notify", "/notify test", "Send a test notification", "/notify test"),
|
|
65
|
+
|
|
59
66
|
# Theme
|
|
60
67
|
SlashCommand("Theme", "/theme", "Show current theme / usage", "/theme"),
|
|
61
68
|
SlashCommand("Theme", "/theme list", "List available themes", "/theme list"),
|
|
@@ -63,3 +63,14 @@ def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
|
63
63
|
# Prevent the re-exec trick in kiwi_tui.main from firing during tests.
|
|
64
64
|
monkeypatch.setenv("_KIWI_PROC_RENAMED", "1")
|
|
65
65
|
return tmp_path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture(autouse=True)
|
|
69
|
+
def no_desktop_notifications(monkeypatch: pytest.MonkeyPatch) -> list:
|
|
70
|
+
"""Kiwi calls the user back with `notify-send` / `osascript` when its terminal cannot
|
|
71
|
+
show a notification: tests must never pop one up on the developer's desktop."""
|
|
72
|
+
from kiwi_tui import attention
|
|
73
|
+
|
|
74
|
+
launched: list = []
|
|
75
|
+
monkeypatch.setattr(attention, "launch", launched.append)
|
|
76
|
+
return launched
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Kiwi calls the user back when a question waits for their answer or a run finishes, while
|
|
2
|
+
they are in another window: a desktop notification, the bell, a mark on the tab."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kiwi_tui import attention
|
|
8
|
+
|
|
9
|
+
GHOSTTY = {"TERM_PROGRAM": "ghostty", "TERM": "xterm-ghostty"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize(
|
|
13
|
+
("env", "expected"),
|
|
14
|
+
[
|
|
15
|
+
(GHOSTTY, "\x1b]777;notify;Kiwi;Pick one, please\x07"),
|
|
16
|
+
({"TERM_PROGRAM": "WezTerm"}, "\x1b]777;notify;Kiwi;Pick one, please\x07"),
|
|
17
|
+
({"TERM_PROGRAM": "iTerm.app"}, "\x1b]9;Kiwi: Pick one; please\x07"),
|
|
18
|
+
({"TERM": "xterm-kitty"}, "\x1b]99;;Kiwi: Pick one; please\x1b\\"),
|
|
19
|
+
({**GHOSTTY, "TMUX": "/tmp/tmux-1/default,1,0"}, ""),
|
|
20
|
+
({"TERM_PROGRAM": "Apple_Terminal"}, ""),
|
|
21
|
+
({}, ""),
|
|
22
|
+
],
|
|
23
|
+
)
|
|
24
|
+
def test_each_terminal_gets_the_notification_it_understands(env, expected) -> None:
|
|
25
|
+
assert attention.notification_sequence("Kiwi", "Pick one; please", env) == expected
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_what_the_agent_wrote_cannot_break_out_of_the_sequence() -> None:
|
|
29
|
+
seq = attention.notification_sequence("Kiwi", "line one\nline\x07two\x1b]0;evil\x07", GHOSTTY)
|
|
30
|
+
assert seq == "\x1b]777;notify;Kiwi;line one line two ]0,evil\x07"
|
|
31
|
+
assert len(attention.notification_sequence("Kiwi", "x" * 500, GHOSTTY)) < 200
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_a_terminal_without_notifications_uses_the_desktops_own_off_ssh(monkeypatch) -> None:
|
|
35
|
+
monkeypatch.setattr(attention.shutil, "which", lambda name: "/usr/bin/notify-send")
|
|
36
|
+
assert attention.local_notification_command("Kiwi", "Done", {}, "linux") == [
|
|
37
|
+
"notify-send", "--app-name=Kiwi", "Kiwi", "Done",
|
|
38
|
+
]
|
|
39
|
+
assert attention.local_notification_command("Kiwi", 'Say "hi"', {}, "darwin") == [
|
|
40
|
+
"osascript", "-e", 'display notification "Say \\"hi\\"" with title "Kiwi"',
|
|
41
|
+
]
|
|
42
|
+
# Over ssh it would show on the wrong machine.
|
|
43
|
+
assert attention.local_notification_command("Kiwi", "Done", {"SSH_CONNECTION": "1 2 3 4"}, "linux") is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize(("mode", "bell"), [("on", True), ("quiet", False)])
|
|
47
|
+
def test_calling_out_marks_the_tab_rings_as_chosen_and_notifies(mode, bell) -> None:
|
|
48
|
+
written: list[str] = []
|
|
49
|
+
attention.call_out(written.append, mode=mode, title="Kiwi", body="Done", tab="✓ kiwi – done", env=GHOSTTY)
|
|
50
|
+
(out,) = written
|
|
51
|
+
assert out.startswith("\x1b]2;✓ kiwi – done\x07")
|
|
52
|
+
# BEL ends the title and the notification too: the bell is a third one.
|
|
53
|
+
assert out.count("\x07") == (3 if bell else 2)
|
|
54
|
+
assert out.endswith("\x1b]777;notify;Kiwi;Done\x07")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_off_does_nothing_and_an_unknown_terminal_uses_the_desktop(no_desktop_notifications, monkeypatch) -> None:
|
|
58
|
+
written: list[str] = []
|
|
59
|
+
attention.call_out(written.append, mode="off", title="Kiwi", body="Done", tab="t", env=GHOSTTY)
|
|
60
|
+
assert written == [] and no_desktop_notifications == []
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr(attention.shutil, "which", lambda name: "/usr/bin/notify-send")
|
|
63
|
+
monkeypatch.setattr(attention.sys, "platform", "linux")
|
|
64
|
+
attention.call_out(written.append, mode="on", title="Kiwi", body="Done", tab="t", env={})
|
|
65
|
+
assert written == ["\x1b]2;t\x07\a"]
|
|
66
|
+
assert no_desktop_notifications == [["notify-send", "--app-name=Kiwi", "Kiwi", "Done"]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_the_mode_is_read_from_the_settings() -> None:
|
|
70
|
+
assert attention.mode_from({}) == "on"
|
|
71
|
+
assert attention.mode_from({"notify": "Quiet"}) == "quiet"
|
|
72
|
+
assert attention.mode_from({"notify": "loud"}) == "on"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_the_app_calls_out_only_when_its_window_is_not_in_focus(isolated_home, monkeypatch) -> None:
|
|
76
|
+
from textual.app import App
|
|
77
|
+
|
|
78
|
+
from kiwi_tui.main import AutobotsTUI
|
|
79
|
+
|
|
80
|
+
class _KiwiApp(App):
|
|
81
|
+
call_for_attention = AutobotsTUI.call_for_attention
|
|
82
|
+
_write_to_terminal = AutobotsTUI._write_to_terminal
|
|
83
|
+
on_app_focus = AutobotsTUI.on_app_focus
|
|
84
|
+
|
|
85
|
+
monkeypatch.setattr(attention, "BASE_TITLE", "kiwi (~/proj)")
|
|
86
|
+
for key, value in GHOSTTY.items():
|
|
87
|
+
monkeypatch.setenv(key, value)
|
|
88
|
+
monkeypatch.delenv("TMUX", raising=False)
|
|
89
|
+
app = _KiwiApp()
|
|
90
|
+
async with app.run_test(size=(100, 30)) as pilot:
|
|
91
|
+
written: list[str] = []
|
|
92
|
+
app._write_to_terminal = written.append
|
|
93
|
+
app.app_focus = True
|
|
94
|
+
assert app.call_for_attention(title="Kiwi", body="Done", tab="✓ kiwi – done") is False
|
|
95
|
+
assert written == []
|
|
96
|
+
|
|
97
|
+
app.app_focus = False
|
|
98
|
+
assert app.call_for_attention(title="Kiwi", body="Done", tab="✓ kiwi – done") is True
|
|
99
|
+
assert "\x1b]777;notify;Kiwi;Done\x07" in written[0]
|
|
100
|
+
|
|
101
|
+
# Back in kiwi: the tab title is put back.
|
|
102
|
+
from textual import events
|
|
103
|
+
|
|
104
|
+
app.post_message(events.AppFocus())
|
|
105
|
+
await pilot.pause(0.05)
|
|
106
|
+
assert written[-1] == "\x1b]2;kiwi (~/proj)\x07"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def test_a_waiting_question_names_itself_and_a_finished_run_its_answer(monkeypatch) -> None:
|
|
110
|
+
from kiwi_tui.screens.term_dashboard_widgets import TermTurn
|
|
111
|
+
from tests.test_term_dashboard_ui import _TermDashboardTestApp
|
|
112
|
+
|
|
113
|
+
app = _TermDashboardTestApp()
|
|
114
|
+
async with app.run_test(size=(100, 30), notifications=True) as pilot:
|
|
115
|
+
await pilot.pause(0.05)
|
|
116
|
+
screen = app.screen
|
|
117
|
+
calls: list[dict] = []
|
|
118
|
+
app.call_for_attention = lambda **kw: calls.append(kw) or True
|
|
119
|
+
turn = TermTurn(user_text="set it up", assistant_text="")
|
|
120
|
+
turn.questions = [
|
|
121
|
+
{"question_id": "q1", "question": "Which database should I use?", "status": "pending"},
|
|
122
|
+
{"question_id": "q2", "question": "Which port?", "status": "pending"},
|
|
123
|
+
{"question_id": "q0", "question": "Old one", "status": "answered"},
|
|
124
|
+
]
|
|
125
|
+
screen._turns = [turn]
|
|
126
|
+
|
|
127
|
+
screen._call_for_attention("question")
|
|
128
|
+
screen._call_for_attention("finished", answer="## Done\nAll tests pass.")
|
|
129
|
+
assert calls == [
|
|
130
|
+
{"title": "Kiwi needs your answer", "body": "Which database should I use? (+1 more)",
|
|
131
|
+
"tab": "● kiwi – waiting for you"},
|
|
132
|
+
{"title": "Kiwi finished", "body": "Done", "tab": "✓ kiwi – done"},
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
# In kiwi already: a question is pointed out on the screen instead.
|
|
136
|
+
app.call_for_attention = lambda **kw: False
|
|
137
|
+
screen._call_for_attention("question")
|
|
138
|
+
await pilot.pause(0.05)
|
|
139
|
+
assert any("waiting for your answer" in str(n.message) for n in app._notifications)
|