kiwi-code 0.0.504__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.504 → kiwi_code-0.0.506}/PKG-INFO +1 -1
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/pyproject.toml +1 -1
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/run_activity.py +59 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/cli.py +6 -0
- kiwi_code-0.0.506/src/kiwi_runtime/fs/edits.py +185 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/handler.py +10 -2
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/ui.py +23 -0
- kiwi_code-0.0.506/src/kiwi_tui/attention.py +107 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/main.py +55 -10
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_activity_view.py +47 -8
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard.py +191 -18
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_css.py +8 -5
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_helpers.py +1 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_dashboard_widgets.py +46 -1
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/slash_commands.py +8 -1
- kiwi_code-0.0.506/src/kiwi_tui/terminal_theme.py +91 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/conftest.py +17 -0
- kiwi_code-0.0.506/tests/test_attention.py +139 -0
- kiwi_code-0.0.506/tests/test_reply_selection.py +69 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_run_activity.py +121 -5
- kiwi_code-0.0.506/tests/test_runtime_edit_diffs.py +130 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_term_dashboard_ui.py +47 -0
- kiwi_code-0.0.506/tests/test_terminal_theme.py +19 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_tui_palette.py +19 -2
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/uv.lock +1 -1
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/.gitignore +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/.python-version +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/Makefile +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/README.md +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/docs/runtime_audit.html +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/docs/social_login_e2e.md +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/scripts/set_edition.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/scripts/ws_auth_check.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/common/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/common/banner.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/_edition.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/autocode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/edition.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/human_questions.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/io_threads.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/oauth.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/subagents.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/auth.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/checkpoints.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/commands.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/constants.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/batch.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/codelens.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/context.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/paths.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/read.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/search.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/text.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/upload.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/fs/write.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/handlers.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/link.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/restricted.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/sessions.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/state.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/tui_bridge.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_runtime/uploads.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/latex_unicode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/role_mode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_question_widgets.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_status_line.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/screens/term_subagent_widgets.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/test_hello.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/__init__.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_account_server.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_autocode_actions.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_command_tips.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_fs_edit_fidelity.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_human_questions.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_io_threads.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_login_social.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_math_and_tables.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_oauth.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_role_mode_injection.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_run_steering.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_cleanup_per_account.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_connection.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_fixes.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_imports_on_windows.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_runtime_social_login.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_stats_command.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_status_line.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_subagents.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_toasts.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.504 → kiwi_code-0.0.506}/tests/test_worktrees.py +0 -0
|
@@ -37,6 +37,8 @@ NOTES_KEPT = 3
|
|
|
37
37
|
#: The thinking blocks of a turn whose text is kept, and how much of each.
|
|
38
38
|
THOUGHTS_KEPT = 20
|
|
39
39
|
THOUGHT_CHARS = 8000
|
|
40
|
+
#: The file changes whose diffs are kept, the latest only; older ones keep their counts.
|
|
41
|
+
DIFFS_KEPT = 30
|
|
40
42
|
#: The trace lines kept for the trace panel.
|
|
41
43
|
TRACE_LINES = 200
|
|
42
44
|
_TRACE_TEXT = 220
|
|
@@ -61,6 +63,9 @@ class ToolLine:
|
|
|
61
63
|
args: str = "" # the server's preview of its arguments, as indented JSON
|
|
62
64
|
args_chars: int | None = None
|
|
63
65
|
args_truncated: bool = False
|
|
66
|
+
#: The changes it made to files, as the user's runtime recorded them: path, added,
|
|
67
|
+
#: removed, diff (unified, without its header), truncated, created, deleted.
|
|
68
|
+
edits: list[dict] = field(default_factory=list)
|
|
64
69
|
|
|
65
70
|
|
|
66
71
|
@dataclass
|
|
@@ -252,6 +257,10 @@ class RunActivityView:
|
|
|
252
257
|
self._followed: tuple[str, int] | None = None
|
|
253
258
|
self._following = False
|
|
254
259
|
self.trace: deque[TraceLine] = deque(maxlen=TRACE_LINES)
|
|
260
|
+
# Each call's file changes, kept apart from the turns so a rebuild keeps them.
|
|
261
|
+
self._edits: dict[str, list[dict]] = {}
|
|
262
|
+
self._with_diff: deque[dict] = deque()
|
|
263
|
+
self._edits_seen: deque[tuple] = deque(maxlen=DIFFS_KEPT * 8)
|
|
255
264
|
self.trace_start: int | None = None
|
|
256
265
|
#: Counts every change, so a redraw can tell whether there is anything new.
|
|
257
266
|
self.version = 0
|
|
@@ -348,6 +357,54 @@ class RunActivityView:
|
|
|
348
357
|
turn = self.followed
|
|
349
358
|
return turn is not None and turn.finished is not None
|
|
350
359
|
|
|
360
|
+
# ---- file changes ----
|
|
361
|
+
|
|
362
|
+
def add_edit(self, record: object) -> bool:
|
|
363
|
+
"""A change the user's runtime made to a file, shown under the call that made it.
|
|
364
|
+
A record that names no call (an older backend) goes to the latest file tool of
|
|
365
|
+
the turn being followed. Whether it found a call."""
|
|
366
|
+
if not isinstance(record, dict):
|
|
367
|
+
return False
|
|
368
|
+
seen = (record.get("at"), record.get("call_id"), record.get("path"), record.get("op"))
|
|
369
|
+
if seen in self._edits_seen:
|
|
370
|
+
return False # read again after the record file was trimmed
|
|
371
|
+
self._edits_seen.append(seen)
|
|
372
|
+
call_id = str(record.get("call_id") or "")
|
|
373
|
+
tool = self._tool(call_id) if call_id else None
|
|
374
|
+
if not call_id:
|
|
375
|
+
tool = self._latest_file_tool()
|
|
376
|
+
if tool is None:
|
|
377
|
+
return False
|
|
378
|
+
call_id = tool.call_id
|
|
379
|
+
edit = {k: record.get(k) for k in ("path", "added", "removed", "diff", "truncated", "created", "deleted")}
|
|
380
|
+
edits = self._edits.setdefault(call_id, [])
|
|
381
|
+
edits.append(edit)
|
|
382
|
+
if tool is not None:
|
|
383
|
+
tool.edits = edits
|
|
384
|
+
self._with_diff.append(edit)
|
|
385
|
+
while len(self._with_diff) > DIFFS_KEPT:
|
|
386
|
+
self._with_diff.popleft()["diff"] = ""
|
|
387
|
+
if len(self._edits) > DIFFS_KEPT * 4:
|
|
388
|
+
for key in list(self._edits)[: len(self._edits) - DIFFS_KEPT * 4]:
|
|
389
|
+
del self._edits[key]
|
|
390
|
+
self.version += 1
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
def _tool(self, call_id: str) -> ToolLine | None:
|
|
394
|
+
for turn in reversed(self._turns):
|
|
395
|
+
if call_id in turn.tools:
|
|
396
|
+
return turn.tools[call_id]
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
def _latest_file_tool(self) -> ToolLine | None:
|
|
400
|
+
turn = self.followed
|
|
401
|
+
if turn is None:
|
|
402
|
+
return None
|
|
403
|
+
for tool in reversed(list(turn.tools.values())):
|
|
404
|
+
if tool.name == "file_system_tool":
|
|
405
|
+
return tool
|
|
406
|
+
return None
|
|
407
|
+
|
|
351
408
|
# ---- trace ----
|
|
352
409
|
|
|
353
410
|
def _trace(self, event: dict) -> None:
|
|
@@ -451,6 +508,8 @@ class RunActivityView:
|
|
|
451
508
|
call_id=call_id, name=str(event.get("name") or "tool"), step=step,
|
|
452
509
|
parent=event.get("parent_call_id"),
|
|
453
510
|
)
|
|
511
|
+
if call_id in self._edits:
|
|
512
|
+
tool.edits = self._edits[call_id]
|
|
454
513
|
turn.tools[call_id] = tool
|
|
455
514
|
turn.tool_total += 1
|
|
456
515
|
turn._add(tool)
|
|
@@ -42,6 +42,7 @@ from kiwi_runtime.ui import (
|
|
|
42
42
|
BackgroundWriter,
|
|
43
43
|
C,
|
|
44
44
|
print_banner,
|
|
45
|
+
print_edit,
|
|
45
46
|
print_section,
|
|
46
47
|
print_status,
|
|
47
48
|
)
|
|
@@ -361,6 +362,11 @@ def _run_until_stopped(loop: asyncio.AbstractEventLoop, runtime) -> None:
|
|
|
361
362
|
|
|
362
363
|
terminal = sys.stdout
|
|
363
364
|
sys.stdout = BackgroundWriter(terminal)
|
|
365
|
+
if getattr(terminal, "isatty", lambda: False)():
|
|
366
|
+
# Each change made to a file shows its diff here; not in a log file.
|
|
367
|
+
from kiwi_runtime.fs import edits
|
|
368
|
+
|
|
369
|
+
edits.show = print_edit
|
|
364
370
|
|
|
365
371
|
try:
|
|
366
372
|
loop.run_until_complete(runtime)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""The diff of each change the runtime makes to a file, for the TUI to show under the tool
|
|
2
|
+
call that made it.
|
|
3
|
+
|
|
4
|
+
One JSON line per change, in `~/.kiwi/edits/<run_id>.jsonl`:
|
|
5
|
+
|
|
6
|
+
{"v": 1, "at": 1760…, "run_id": "…", "call_id": "call_8f2a", "op": "replace_in_file",
|
|
7
|
+
"path": "src/app.py", "added": 2, "removed": 1, "diff": "@@ -3,3 +3,4 @@\\n…",
|
|
8
|
+
"truncated": false, "created": false, "deleted": false}
|
|
9
|
+
|
|
10
|
+
The backend names the tool call (`call_id`) and run (`run_id`) in each request; a backend
|
|
11
|
+
that does not falls back to the run this runtime is bound to. Recording a change never
|
|
12
|
+
stops the change: anything that goes wrong here is skipped.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextvars
|
|
17
|
+
import difflib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
#: The operations that change a file's content.
|
|
27
|
+
MUTATING = frozenset({
|
|
28
|
+
"write_file", "append_file", "replace_in_file", "replace_line_range", "insert_at_line",
|
|
29
|
+
"apply_unified_diff", "file_write_finish", "delete_path",
|
|
30
|
+
})
|
|
31
|
+
#: Files larger than this are not read to diff (a log or a data dump is not worth it).
|
|
32
|
+
MAX_FILE_BYTES = 2_000_000
|
|
33
|
+
#: A diff is kept to this many lines; the counts still cover all of it.
|
|
34
|
+
MAX_DIFF_LINES = 400
|
|
35
|
+
#: A run's record file is trimmed to its latest changes once it grows past this.
|
|
36
|
+
MAX_RECORD_BYTES = 1_000_000
|
|
37
|
+
KEEP_RECORDS = 200
|
|
38
|
+
|
|
39
|
+
#: Called with each record as it is made, e.g. to print the diff in the runtime's terminal.
|
|
40
|
+
show: Callable[[dict[str, Any]], None] | None = None
|
|
41
|
+
|
|
42
|
+
_lock = threading.Lock()
|
|
43
|
+
# The tool call a batch belongs to, for the operations inside it, which do not name it.
|
|
44
|
+
_LINK: contextvars.ContextVar[tuple[str, str] | None] = contextvars.ContextVar("kiwi_edit_link", default=None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def edits_dir() -> Path:
|
|
48
|
+
try:
|
|
49
|
+
from kiwi_cli.checkpoints import kiwi_home_dir
|
|
50
|
+
|
|
51
|
+
return kiwi_home_dir() / "edits"
|
|
52
|
+
except Exception:
|
|
53
|
+
return Path.home() / ".kiwi" / "edits"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def edits_path(run_id: str) -> Path:
|
|
57
|
+
return edits_dir() / f"{re.sub(r'[^A-Za-z0-9_.-]', '_', run_id)}.jsonl"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def link(msg: dict[str, Any]) -> contextvars.Token | None:
|
|
61
|
+
"""While a request runs, what it says about its tool call and run, so the operations
|
|
62
|
+
of a batch inside it are recorded against the same call."""
|
|
63
|
+
call_id, run_id = str(msg.get("call_id") or ""), str(msg.get("run_id") or "")
|
|
64
|
+
if not (call_id or run_id):
|
|
65
|
+
return None
|
|
66
|
+
return _LINK.set((call_id, run_id))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def unlink(token: contextvars.Token | None) -> None:
|
|
70
|
+
if token is not None:
|
|
71
|
+
_LINK.reset(token)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def before(msg: dict[str, Any], *, mode: str, allowed_dirs: list[str] | None, chunked_writes: dict) -> tuple[str, str | None] | None:
|
|
75
|
+
"""The file a change is about to touch and its text now (None if it cannot be read as
|
|
76
|
+
text), or None when the request changes no file."""
|
|
77
|
+
try:
|
|
78
|
+
op = msg.get("type")
|
|
79
|
+
if op not in MUTATING:
|
|
80
|
+
return None
|
|
81
|
+
if op == "file_write_finish":
|
|
82
|
+
state = chunked_writes.get(msg.get("upload_id"))
|
|
83
|
+
path = getattr(state, "path", None)
|
|
84
|
+
else:
|
|
85
|
+
from kiwi_runtime.restricted import validate_fs_path
|
|
86
|
+
|
|
87
|
+
raw = msg.get("path")
|
|
88
|
+
if not raw:
|
|
89
|
+
return None
|
|
90
|
+
ok, path, _reason = validate_fs_path(raw, mode, allowed_dirs)
|
|
91
|
+
if not ok:
|
|
92
|
+
return None
|
|
93
|
+
if not path or os.path.isdir(path):
|
|
94
|
+
return None
|
|
95
|
+
return str(path), _read_text(str(path))
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def after(msg: dict[str, Any], watched: tuple[str, str | None]) -> None:
|
|
101
|
+
"""Record the change a successful request made to the file `before` watched."""
|
|
102
|
+
try:
|
|
103
|
+
path, old = watched
|
|
104
|
+
if old is None:
|
|
105
|
+
return # not text, or too large to diff
|
|
106
|
+
new = "" if msg.get("type") == "delete_path" else _read_text(path)
|
|
107
|
+
if new is None or new == old:
|
|
108
|
+
return
|
|
109
|
+
call_id, run_id = _LINK.get() or ("", "")
|
|
110
|
+
call_id = str(msg.get("call_id") or call_id)
|
|
111
|
+
run_id = str(msg.get("run_id") or run_id or _bound_run_id())
|
|
112
|
+
if not run_id:
|
|
113
|
+
return
|
|
114
|
+
record = _record(msg.get("type"), path, old, new, call_id=call_id, run_id=run_id)
|
|
115
|
+
_append(edits_path(run_id), record)
|
|
116
|
+
if show is not None:
|
|
117
|
+
show(record)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _record(op: Any, path: str, old: str, new: str, *, call_id: str, run_id: str) -> dict[str, Any]:
|
|
123
|
+
lines = list(difflib.unified_diff(
|
|
124
|
+
old.splitlines(), new.splitlines(), lineterm="", n=3,
|
|
125
|
+
))[2:] # without the ---/+++ header: the path is shown on its own
|
|
126
|
+
added = sum(1 for line in lines if line.startswith("+"))
|
|
127
|
+
removed = sum(1 for line in lines if line.startswith("-"))
|
|
128
|
+
return {
|
|
129
|
+
"v": 1,
|
|
130
|
+
"at": time.time(),
|
|
131
|
+
"run_id": run_id,
|
|
132
|
+
"call_id": call_id,
|
|
133
|
+
"op": str(op or ""),
|
|
134
|
+
"path": _display_path(path),
|
|
135
|
+
"added": added,
|
|
136
|
+
"removed": removed,
|
|
137
|
+
"diff": "\n".join(lines[:MAX_DIFF_LINES]),
|
|
138
|
+
"truncated": len(lines) > MAX_DIFF_LINES,
|
|
139
|
+
"created": old == "" and op != "delete_path",
|
|
140
|
+
"deleted": op == "delete_path",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _read_text(path: str) -> str | None:
|
|
145
|
+
"""The file's text; "" for a file not there yet; None for one too large or not text."""
|
|
146
|
+
try:
|
|
147
|
+
if not os.path.exists(path):
|
|
148
|
+
return ""
|
|
149
|
+
if os.path.getsize(path) > MAX_FILE_BYTES:
|
|
150
|
+
return None
|
|
151
|
+
with open(path, "rb") as f:
|
|
152
|
+
data = f.read()
|
|
153
|
+
if b"\0" in data[:8192]:
|
|
154
|
+
return None
|
|
155
|
+
return data.decode("utf-8")
|
|
156
|
+
except (OSError, UnicodeDecodeError):
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _display_path(path: str) -> str:
|
|
161
|
+
try:
|
|
162
|
+
rel = os.path.relpath(path)
|
|
163
|
+
return path if rel.startswith("..") else rel
|
|
164
|
+
except ValueError:
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _bound_run_id() -> str:
|
|
169
|
+
try:
|
|
170
|
+
from kiwi_runtime.checkpoints import _get_current_run_id
|
|
171
|
+
|
|
172
|
+
return _get_current_run_id()
|
|
173
|
+
except Exception:
|
|
174
|
+
return ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _append(target: Path, record: dict[str, Any]) -> None:
|
|
178
|
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
179
|
+
with _lock:
|
|
180
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
with open(target, "a", encoding="utf-8") as f:
|
|
182
|
+
f.write(line)
|
|
183
|
+
if target.stat().st_size > MAX_RECORD_BYTES:
|
|
184
|
+
kept = target.read_text(encoding="utf-8").splitlines(keepends=True)[-KEEP_RECORDS:]
|
|
185
|
+
target.write_text("".join(kept), encoding="utf-8")
|
|
@@ -7,7 +7,7 @@ from concurrent.futures import ProcessPoolExecutor
|
|
|
7
7
|
from concurrent.futures.process import BrokenProcessPool
|
|
8
8
|
from typing import Any, Callable
|
|
9
9
|
|
|
10
|
-
from kiwi_runtime.fs import batch, codelens, paths, read, search, upload, write
|
|
10
|
+
from kiwi_runtime.fs import batch, codelens, edits, paths, read, search, upload, write
|
|
11
11
|
from kiwi_runtime.fs.context import ChunkedWriteState, FsRequest
|
|
12
12
|
|
|
13
13
|
FS_MAX_CONCURRENCY = 8
|
|
@@ -62,14 +62,22 @@ def _handle_fs_request_sync(
|
|
|
62
62
|
"message": f"Unsupported file operation type: {msg_type}",
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
# A change to a file is recorded with its diff, for the TUI to show under the call.
|
|
66
|
+
token = edits.link(msg)
|
|
67
|
+
watched = edits.before(msg, mode=mode, allowed_dirs=allowed_dirs, chunked_writes=chunked_writes)
|
|
65
68
|
try:
|
|
66
|
-
|
|
69
|
+
result = handler(FsRequest(msg, request_id, mode, allowed_dirs, chunked_writes))
|
|
67
70
|
except Exception as e:
|
|
68
71
|
return {
|
|
69
72
|
"request_id": request_id,
|
|
70
73
|
"success": False,
|
|
71
74
|
"message": str(e),
|
|
72
75
|
}
|
|
76
|
+
finally:
|
|
77
|
+
edits.unlink(token)
|
|
78
|
+
if watched is not None and isinstance(result, dict) and result.get("success"):
|
|
79
|
+
edits.after(msg, watched)
|
|
80
|
+
return result
|
|
73
81
|
|
|
74
82
|
|
|
75
83
|
# Pure-Python scans over many files. In a thread they would share the interpreter with the
|
|
@@ -234,3 +234,26 @@ def print_pty_log(session_id: str, message: str, level: str = "info"):
|
|
|
234
234
|
else:
|
|
235
235
|
icon = f"{C}~{RESET}"
|
|
236
236
|
_safe_print(f" {icon} {tag} {message}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
#: A diff printed under its request is kept to this many lines.
|
|
241
|
+
SHOWN_DIFF_LINES = 40
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def print_edit(record: dict) -> None:
|
|
245
|
+
"""The diff of a change made to a file, under the request that made it."""
|
|
246
|
+
added, removed = record.get("added") or 0, record.get("removed") or 0
|
|
247
|
+
what = "new file" if record.get("created") else "deleted" if record.get("deleted") else "edited"
|
|
248
|
+
lines = [
|
|
249
|
+
f" {GREY}{what}{RESET} {BOLD}{record.get('path', '')}{RESET} "
|
|
250
|
+
f"{GREEN}+{added}{RESET} {RED}-{removed}{RESET}"
|
|
251
|
+
]
|
|
252
|
+
diff = str(record.get("diff") or "").splitlines()
|
|
253
|
+
for line in diff[:SHOWN_DIFF_LINES]:
|
|
254
|
+
color = GREEN if line.startswith("+") else RED if line.startswith("-") else C if line.startswith("@@") else GREY
|
|
255
|
+
lines.append(f" {color}{line}{RESET}")
|
|
256
|
+
more = len(diff) - SHOWN_DIFF_LINES
|
|
257
|
+
if more > 0 or record.get("truncated"):
|
|
258
|
+
lines.append(f" {GREY}… more lines not shown{RESET}")
|
|
259
|
+
_safe_print("\n".join(lines))
|
|
@@ -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
|
|
|
@@ -309,9 +310,20 @@ class AutobotsTUI(App):
|
|
|
309
310
|
|
|
310
311
|
|
|
311
312
|
DEFAULT_THEME = "kiwi-black"
|
|
312
|
-
#:
|
|
313
|
-
#: stays Kiwi's own palette, and the fallback
|
|
314
|
-
|
|
313
|
+
#: What a new install starts with, and `/theme reset` returns to: the terminal's own
|
|
314
|
+
#: look (see `initial_theme`). `DEFAULT_THEME` stays Kiwi's own palette, and the fallback
|
|
315
|
+
#: when a theme cannot be applied.
|
|
316
|
+
INITIAL_THEME = "terminal"
|
|
317
|
+
|
|
318
|
+
def initial_theme(self) -> str:
|
|
319
|
+
"""The theme that follows the terminal: its background and colours, light or dark
|
|
320
|
+
as the terminal is. Asked once, before the TUI takes over the terminal."""
|
|
321
|
+
theme = getattr(self, "_terminal_theme", None)
|
|
322
|
+
if theme is None:
|
|
323
|
+
from kiwi_tui.terminal_theme import terminal_theme
|
|
324
|
+
|
|
325
|
+
theme = self._terminal_theme = terminal_theme()
|
|
326
|
+
return theme
|
|
315
327
|
|
|
316
328
|
def _register_kiwi_theme(self) -> None:
|
|
317
329
|
"""Register Kiwi's default theme with Textual's theme system."""
|
|
@@ -347,17 +359,17 @@ class AutobotsTUI(App):
|
|
|
347
359
|
settings = load_ui_settings()
|
|
348
360
|
stored = settings.get("theme") if isinstance(settings, dict) else None
|
|
349
361
|
if isinstance(stored, str) and stored.strip():
|
|
350
|
-
return stored.strip()
|
|
362
|
+
return self.initial_theme() if stored.strip().lower() == "terminal" else stored.strip()
|
|
351
363
|
|
|
352
364
|
t = str(getattr(self.config, "theme", "") or "").strip()
|
|
353
365
|
tl = t.lower()
|
|
354
|
-
if tl in {"dark", "default"}:
|
|
355
|
-
return self.
|
|
366
|
+
if tl in {"dark", "default", "terminal", ""}:
|
|
367
|
+
return self.initial_theme()
|
|
356
368
|
if tl == "black":
|
|
357
369
|
return self.DEFAULT_THEME
|
|
358
370
|
if tl in {"light"}:
|
|
359
371
|
return "textual-light"
|
|
360
|
-
return t
|
|
372
|
+
return t
|
|
361
373
|
|
|
362
374
|
def set_theme_preference(self, theme_name: str, *, persist: bool = True) -> tuple[bool, str]:
|
|
363
375
|
"""Set the active theme and (optionally) persist it to ~/.kiwi/settings.json."""
|
|
@@ -366,8 +378,12 @@ class AutobotsTUI(App):
|
|
|
366
378
|
return False, "Usage: /theme set <name>"
|
|
367
379
|
|
|
368
380
|
norm = raw
|
|
381
|
+
saved = None
|
|
369
382
|
nl = raw.lower()
|
|
370
|
-
if nl
|
|
383
|
+
if nl == "terminal":
|
|
384
|
+
# Follow the terminal: remembered as such, so the next start asks it again.
|
|
385
|
+
norm, saved = self.initial_theme(), "terminal"
|
|
386
|
+
elif nl in {"dark", "black", "default"}:
|
|
371
387
|
norm = self.DEFAULT_THEME
|
|
372
388
|
elif nl in {"light"}:
|
|
373
389
|
norm = "textual-light"
|
|
@@ -383,9 +399,11 @@ class AutobotsTUI(App):
|
|
|
383
399
|
|
|
384
400
|
if persist:
|
|
385
401
|
try:
|
|
386
|
-
update_ui_settings({"theme": norm})
|
|
402
|
+
update_ui_settings({"theme": saved or norm})
|
|
387
403
|
except Exception:
|
|
388
404
|
pass
|
|
405
|
+
if saved == "terminal":
|
|
406
|
+
return True, f"Theme follows your terminal ({norm})"
|
|
389
407
|
return True, f"Theme set to: {norm}"
|
|
390
408
|
def _is_dark_theme(self) -> bool:
|
|
391
409
|
"""Best-effort detection of whether the active theme is dark."""
|
|
@@ -885,6 +903,32 @@ class AutobotsTUI(App):
|
|
|
885
903
|
#: The most toasts shown at once; older ones give way.
|
|
886
904
|
MAX_TOASTS = 3
|
|
887
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
|
+
|
|
888
932
|
def _on_notify(self, event) -> None:
|
|
889
933
|
make_room_for_toast(self._notifications, event.notification, self.MAX_TOASTS)
|
|
890
934
|
super()._on_notify(event)
|
|
@@ -1280,7 +1324,8 @@ def main() -> int:
|
|
|
1280
1324
|
if sys.stdout.isatty():
|
|
1281
1325
|
# Strip C0 control chars and DEL to prevent terminal injection via crafted dir names.
|
|
1282
1326
|
safe_dir = "".join(ch for ch in display_dir if ch >= " " and ch != "\x7f")
|
|
1283
|
-
|
|
1327
|
+
attention.BASE_TITLE = f"kiwi ({safe_dir})"
|
|
1328
|
+
sys.stdout.write(f"\033]0;{attention.BASE_TITLE}\007")
|
|
1284
1329
|
sys.stdout.flush()
|
|
1285
1330
|
|
|
1286
1331
|
runtime_args = _build_runtime_args(args)
|