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.
Files changed (141) hide show
  1. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/pyproject.toml +1 -1
  3. kiwi_code-0.0.506/src/kiwi_tui/attention.py +107 -0
  4. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/main.py +29 -1
  5. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_activity_view.py +5 -5
  6. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard.py +123 -16
  7. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_css.py +8 -5
  8. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_helpers.py +1 -0
  9. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/slash_commands.py +7 -0
  10. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/conftest.py +11 -0
  11. kiwi_code-0.0.506/tests/test_attention.py +139 -0
  12. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_run_activity.py +40 -5
  13. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_term_dashboard_ui.py +47 -0
  14. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/uv.lock +1 -1
  15. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/.github/workflows/publish.yml +0 -0
  16. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/.github/workflows/test.yml +0 -0
  17. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/.gitignore +0 -0
  18. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/.python-version +0 -0
  19. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/Makefile +0 -0
  20. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/README.md +0 -0
  21. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/docs/runtime_audit.html +0 -0
  22. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/docs/social_login_e2e.md +0 -0
  23. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/scripts/set_edition.py +0 -0
  24. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/scripts/ws_auth_check.py +0 -0
  25. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/common/__init__.py +0 -0
  26. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/common/banner.py +0 -0
  27. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/__init__.py +0 -0
  28. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/_edition.py +0 -0
  29. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/auth.py +0 -0
  30. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/autocode.py +0 -0
  31. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/checkpoints.py +0 -0
  32. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/cli.py +0 -0
  33. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/client.py +0 -0
  34. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/commands.py +0 -0
  35. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/edition.py +0 -0
  36. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/human_questions.py +0 -0
  37. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/io_threads.py +0 -0
  38. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/logger.py +0 -0
  39. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/models.py +0 -0
  40. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/oauth.py +0 -0
  41. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/run_activity.py +0 -0
  42. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/runtime_manager.py +0 -0
  43. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/server.py +0 -0
  44. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/subagents.py +0 -0
  45. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_cli/terminal_mode.py +0 -0
  46. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/__init__.py +0 -0
  47. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/__main__.py +0 -0
  48. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/auth.py +0 -0
  49. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/checkpoints.py +0 -0
  50. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/cli.py +0 -0
  51. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/commands.py +0 -0
  52. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/constants.py +0 -0
  53. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/__init__.py +0 -0
  54. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/batch.py +0 -0
  55. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/codelens.py +0 -0
  56. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/context.py +0 -0
  57. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/edits.py +0 -0
  58. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/handler.py +0 -0
  59. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/paths.py +0 -0
  60. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/read.py +0 -0
  61. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/search.py +0 -0
  62. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/text.py +0 -0
  63. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/upload.py +0 -0
  64. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/write.py +0 -0
  65. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/handlers.py +0 -0
  66. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/link.py +0 -0
  67. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/main.py +0 -0
  68. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/restricted.py +0 -0
  69. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/sessions.py +0 -0
  70. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/state.py +0 -0
  71. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/tui_bridge.py +0 -0
  72. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/ui.py +0 -0
  73. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_runtime/uploads.py +0 -0
  74. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/__init__.py +0 -0
  75. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/inline_file_picker.py +0 -0
  76. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/latex_unicode.py +0 -0
  77. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/random_words.py +0 -0
  78. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/role_mode.py +0 -0
  79. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/runtime_agent.py +0 -0
  80. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/__init__.py +0 -0
  81. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/attach_content.py +0 -0
  82. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/command_result.py +0 -0
  83. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/detach_files.py +0 -0
  84. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/file_browser.py +0 -0
  85. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/help.py +0 -0
  86. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/id_picker.py +0 -0
  87. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/login.py +0 -0
  88. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  89. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  90. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/slash_picker.py +0 -0
  91. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_widgets.py +0 -0
  92. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
  93. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_status_line.py +0 -0
  94. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
  95. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/status_words.py +0 -0
  96. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/term_app.py +0 -0
  97. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/terminal_theme.py +0 -0
  98. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/ui_settings.py +0 -0
  99. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/widgets.py +0 -0
  100. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/src/kiwi_tui/worktrees.py +0 -0
  101. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/test_hello.py +0 -0
  102. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/__init__.py +0 -0
  103. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_account_server.py +0 -0
  104. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_autocode_actions.py +0 -0
  105. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_batch_fs_tool.py +0 -0
  106. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_checkpoints.py +0 -0
  107. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_cli_help.py +0 -0
  108. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_command_tips.py +0 -0
  109. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_copy_path_fs_tool.py +0 -0
  110. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_fs_edit_fidelity.py +0 -0
  111. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_human_questions.py +0 -0
  112. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_imports.py +0 -0
  113. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_io_threads.py +0 -0
  114. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_login_social.py +0 -0
  115. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_math_and_tables.py +0 -0
  116. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_oauth.py +0 -0
  117. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_read_file_streaming.py +0 -0
  118. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_reexec_kiwi.py +0 -0
  119. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_reply_selection.py +0 -0
  120. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_role_mode_injection.py +0 -0
  121. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_run_steering.py +0 -0
  122. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_cleanup_per_account.py +0 -0
  123. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_connection.py +0 -0
  124. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_edit_diffs.py +0 -0
  125. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_fixes.py +0 -0
  126. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_imports_on_windows.py +0 -0
  127. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_log_trimming.py +0 -0
  128. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_runtime_social_login.py +0 -0
  129. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_search_in_files_fs_tool.py +0 -0
  130. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_slash_commands.py +0 -0
  131. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_stats_command.py +0 -0
  132. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_status_line.py +0 -0
  133. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_subagents.py +0 -0
  134. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_terminal_mode.py +0 -0
  135. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_terminal_theme.py +0 -0
  136. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_toasts.py +0 -0
  137. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_tokens.py +0 -0
  138. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_tui_interactive_runtime.py +0 -0
  139. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_tui_palette.py +0 -0
  140. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_unified_diff.py +0 -0
  141. {kiwi_code-0.0.505 → kiwi_code-0.0.506}/tests/test_worktrees.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kiwi-code
3
- Version: 0.0.505
3
+ Version: 0.0.506
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://kiwicode.ai
6
6
  Author-email: Anurag <anurag@meetkiwi.co>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.505"
3
+ version = "0.0.506"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -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
- sys.stdout.write(f"\033]0;kiwi ({safe_dir})\007")
1327
+ attention.BASE_TITLE = f"kiwi ({safe_dir})"
1328
+ sys.stdout.write(f"\033]0;{attention.BASE_TITLE}\007")
1301
1329
  sys.stdout.flush()
1302
1330
 
1303
1331
  runtime_args = _build_runtime_args(args)
@@ -313,17 +313,17 @@ def _spaced(parts: list) -> list:
313
313
  def trace_renderable(
314
314
  lines: list[TraceLine], start: int | None, *, cyan: str, total: int, shown: int = SHOWN_TRACE
315
315
  ) -> Text:
316
- """The latest `shown` trace lines: number, time since the first event, type, what it says."""
316
+ """The latest `shown` trace lines: time since the first event, type, what it says.
317
+ Kept narrow: the trace is a column beside the conversation."""
317
318
  families = {"turn": cyan, "step": "dim", "tool": "yellow", "subagent": "magenta", "question": "blue"}
318
319
  out = Text()
319
- out.append(f"Trace · latest {min(len(lines), shown)} of {total} kept · esc or ctrl+t to hide", style="dim")
320
+ out.append(f"Trace · {min(len(lines), shown)} of {total} · esc to close", style="dim")
320
321
  for line in lines[-shown:]:
321
322
  family = line.type.split(".", 1)[0]
322
323
  secs = ((line.at - (start or line.at)) / 1000) if line.at else 0
323
324
  out.append("\n")
324
- out.append(f"{line.seq:>5} ", style="dim")
325
- out.append(f"+{secs:.2f}s".ljust(10), style="dim")
326
- out.append(f"{line.type:<18}", style=families.get(family, ""))
325
+ out.append(f"+{secs:.1f}s".ljust(8), style="dim")
326
+ out.append(f"{line.type:<17}", style=families.get(family, ""))
327
327
  what = line.what if line.count == 1 else f"×{line.count} {line.what}"
328
328
  out.append(what)
329
329
  return out
@@ -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 VerticalScroll(id="term-scroll-transcript"):
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
- if near_bottom and at_tail:
411
- self._follow_tail = True
412
- elif not near_bottom:
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
- return
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=6)
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=6)
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._refresh_trace_panel(force=True)
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 getattr(self, "_trace_shown", False):
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
- # About two fifths of the terminal, so the conversation stays in view.
5613
- shown = max(8, int(self.size.height * 0.4) - 1)
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 or ctrl+t to hide")
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
- height: auto;
234
- max-height: 40%;
235
- width: 100%;
236
- padding: 0 2;
237
- margin: 1 0 0 0;
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 {
@@ -30,6 +30,7 @@ from kiwi_cli.commands import (
30
30
  STREAMING_SAFE_SLASH_PREFIXES = (
31
31
  "/help",
32
32
  "/theme",
33
+ "/notify",
33
34
  "/show-logs",
34
35
  "/status",
35
36
  "/cancel",
@@ -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)