kiwi-code 0.0.24__tar.gz → 0.0.26__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.24 → kiwi_code-0.0.26}/PKG-INFO +1 -1
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/pyproject.toml +1 -1
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/runtime_agent.py +119 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/dashboard.py +11 -0
- kiwi_code-0.0.26/src/kiwi_tui/screens/help.py +219 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/runtime_logs.py +30 -3
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/slash_picker.py +13 -32
- kiwi_code-0.0.26/src/kiwi_tui/slash_commands.py +60 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/widgets.py +4 -28
- kiwi_code-0.0.26/tests/test_runtime_log_trimming.py +43 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/uv.lock +1 -1
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/.gitignore +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/.python-version +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/CLAUDE.md +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/Makefile +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/README.md +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/cli.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/test_hello.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/__init__.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/conftest.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_tui_headless.py +0 -0
|
@@ -27,6 +27,8 @@ import json
|
|
|
27
27
|
import os
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
import subprocess
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
30
32
|
from typing import Any, Literal
|
|
31
33
|
import uuid
|
|
32
34
|
|
|
@@ -57,6 +59,111 @@ BY_RUN_DIR = RUNTIMES_DIR / "by-run"
|
|
|
57
59
|
PENDING_DIR = RUNTIMES_DIR / "pending"
|
|
58
60
|
|
|
59
61
|
|
|
62
|
+
# Runtime log truncation
|
|
63
|
+
# ---------------------
|
|
64
|
+
# Runtime logs are written to a local file and can otherwise grow without bound,
|
|
65
|
+
# consuming both disk and memory (the logs viewer historically read the entire
|
|
66
|
+
# file into RAM). We keep only a small trailing window so the user sees the most
|
|
67
|
+
# recent activity.
|
|
68
|
+
MAX_RUNTIME_LOG_BYTES = 30 * 1024
|
|
69
|
+
_RUNTIME_LOG_TRIM_INTERVAL_SEC = 0.5
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _trim_file_to_tail_bytes(path: Path, *, max_bytes: int = MAX_RUNTIME_LOG_BYTES) -> None:
|
|
73
|
+
"""Trim a file in-place to keep only the last ``max_bytes`` bytes.
|
|
74
|
+
|
|
75
|
+
Notes:
|
|
76
|
+
- We *cannot* implement trimming via atomic replace (write temp + rename),
|
|
77
|
+
because the runtime process inherits an open file descriptor; replacing the
|
|
78
|
+
path would orphan the original inode and the runtime would keep writing to
|
|
79
|
+
the unlinked file.
|
|
80
|
+
- This is therefore best-effort; concurrent appends may result in dropping a
|
|
81
|
+
few bytes around the trim boundary, which is acceptable for log viewing.
|
|
82
|
+
"""
|
|
83
|
+
if max_bytes <= 0:
|
|
84
|
+
return
|
|
85
|
+
try:
|
|
86
|
+
if not path.exists():
|
|
87
|
+
return
|
|
88
|
+
with open(path, "r+b") as fp:
|
|
89
|
+
fp.seek(0, os.SEEK_END)
|
|
90
|
+
size = fp.tell()
|
|
91
|
+
if size <= max_bytes:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# Read the tail and try to start at a newline boundary to avoid
|
|
95
|
+
# rendering a partial first line.
|
|
96
|
+
fp.seek(max(0, size - max_bytes), os.SEEK_SET)
|
|
97
|
+
tail = fp.read(max_bytes)
|
|
98
|
+
if size > max_bytes:
|
|
99
|
+
nl = tail.find(b"\n")
|
|
100
|
+
if 0 <= nl < len(tail) - 1:
|
|
101
|
+
tail = tail[nl + 1 :]
|
|
102
|
+
|
|
103
|
+
fp.seek(0, os.SEEK_SET)
|
|
104
|
+
fp.write(tail)
|
|
105
|
+
fp.truncate(len(tail))
|
|
106
|
+
try:
|
|
107
|
+
fp.flush()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
except Exception:
|
|
111
|
+
# Never crash UI / runtime manager due to log trimming.
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _RuntimeLogTrimmer(threading.Thread):
|
|
116
|
+
def __init__(self, *, log_path: Path, pid: int | None, max_bytes: int) -> None:
|
|
117
|
+
super().__init__(daemon=True)
|
|
118
|
+
self._log_path = log_path
|
|
119
|
+
self._pid = pid
|
|
120
|
+
self._max_bytes = max_bytes
|
|
121
|
+
self._stop = threading.Event()
|
|
122
|
+
self._missing_count = 0
|
|
123
|
+
|
|
124
|
+
def stop(self) -> None:
|
|
125
|
+
self._stop.set()
|
|
126
|
+
|
|
127
|
+
def run(self) -> None:
|
|
128
|
+
while not self._stop.is_set():
|
|
129
|
+
try:
|
|
130
|
+
if self._pid and not psutil.pid_exists(self._pid):
|
|
131
|
+
break
|
|
132
|
+
except Exception:
|
|
133
|
+
# If we can't check, keep trimming while the app is alive.
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
if not self._log_path.exists():
|
|
137
|
+
self._missing_count += 1
|
|
138
|
+
# If the log path was moved (e.g. pending -> by-run), another
|
|
139
|
+
# trimmer should be started for the new path.
|
|
140
|
+
if self._missing_count > 20:
|
|
141
|
+
break
|
|
142
|
+
else:
|
|
143
|
+
self._missing_count = 0
|
|
144
|
+
_trim_file_to_tail_bytes(self._log_path, max_bytes=self._max_bytes)
|
|
145
|
+
|
|
146
|
+
self._stop.wait(_RUNTIME_LOG_TRIM_INTERVAL_SEC)
|
|
147
|
+
|
|
148
|
+
# One final trim on exit.
|
|
149
|
+
_trim_file_to_tail_bytes(self._log_path, max_bytes=self._max_bytes)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_LOG_TRIMMERS: dict[str, _RuntimeLogTrimmer] = {}
|
|
153
|
+
_LOG_TRIMMERS_LOCK = threading.Lock()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _ensure_log_trimmer(*, log_path: Path, pid: int | None, max_bytes: int = MAX_RUNTIME_LOG_BYTES) -> None:
|
|
157
|
+
key = str(log_path)
|
|
158
|
+
with _LOG_TRIMMERS_LOCK:
|
|
159
|
+
trimmer = _LOG_TRIMMERS.get(key)
|
|
160
|
+
if trimmer and trimmer.is_alive():
|
|
161
|
+
return
|
|
162
|
+
trimmer = _RuntimeLogTrimmer(log_path=log_path, pid=pid, max_bytes=max_bytes)
|
|
163
|
+
_LOG_TRIMMERS[key] = trimmer
|
|
164
|
+
trimmer.start()
|
|
165
|
+
|
|
166
|
+
|
|
60
167
|
|
|
61
168
|
def _read_pid(path: Path) -> int | None:
|
|
62
169
|
if not path.exists():
|
|
@@ -350,6 +457,9 @@ def _spawn_runtime(
|
|
|
350
457
|
pass
|
|
351
458
|
return False, None, f"Failed to start runtime: {e}"
|
|
352
459
|
|
|
460
|
+
|
|
461
|
+
# Keep runtime log file bounded in the background (avoid disk + RAM blow-ups).
|
|
462
|
+
_ensure_log_trimmer(log_path=log_path, pid=proc.pid)
|
|
353
463
|
try:
|
|
354
464
|
pid_path.write_text(str(proc.pid), encoding="utf-8")
|
|
355
465
|
except Exception:
|
|
@@ -385,6 +495,7 @@ def ensure_runtime_for_run(
|
|
|
385
495
|
existing = get_running_pid_for_run(run_id)
|
|
386
496
|
lp = log_path_for_run(run_id)
|
|
387
497
|
if existing:
|
|
498
|
+
_ensure_log_trimmer(log_path=lp, pid=existing)
|
|
388
499
|
return True, existing, lp, f"Runtime already running for run {run_id} (pid={existing})"
|
|
389
500
|
|
|
390
501
|
ok, pid, msg = _spawn_runtime(
|
|
@@ -530,6 +641,14 @@ def bind_pending_to_run(
|
|
|
530
641
|
except Exception:
|
|
531
642
|
pass
|
|
532
643
|
|
|
644
|
+
|
|
645
|
+
# If the pending runtime directory was moved successfully, start trimming at the
|
|
646
|
+
# new by-run log path (the old path no longer exists).
|
|
647
|
+
try:
|
|
648
|
+
pid = _read_pid(dst / "pid")
|
|
649
|
+
_ensure_log_trimmer(log_path=dst / "log", pid=pid)
|
|
650
|
+
except Exception:
|
|
651
|
+
pass
|
|
533
652
|
return True, dst / "log", f"Bound pending runtime {pending_id} -> run {run_id}"
|
|
534
653
|
|
|
535
654
|
|
|
@@ -12,6 +12,7 @@ from kiwi_tui.screens.attach_content import AttachContentScreen
|
|
|
12
12
|
from kiwi_tui.screens.slash_picker import SlashPickerScreen
|
|
13
13
|
from kiwi_tui.screens.id_picker import IdPickerScreen
|
|
14
14
|
from kiwi_tui.screens.command_result import CommandResultScreen
|
|
15
|
+
from kiwi_tui.screens.help import HelpScreen
|
|
15
16
|
from kiwi_tui.widgets import ChatInput
|
|
16
17
|
from kiwi_tui.inline_file_picker import InlineFilePicker
|
|
17
18
|
from textual.worker import Worker, WorkerState
|
|
@@ -573,6 +574,16 @@ class DashboardScreen(Screen):
|
|
|
573
574
|
# If a command is already running, ignore (prevents the "hung" feeling / double execution).
|
|
574
575
|
if self._cmd_running:
|
|
575
576
|
return
|
|
577
|
+
|
|
578
|
+
# /help is a UI-first experience (picker + copy). Don't run it through the CLI dispatcher.
|
|
579
|
+
if command.strip().lower() == "/help":
|
|
580
|
+
self.app.push_screen(HelpScreen())
|
|
581
|
+
try:
|
|
582
|
+
self.query_one("#chat-input", ChatInput).focus()
|
|
583
|
+
except Exception:
|
|
584
|
+
pass
|
|
585
|
+
return
|
|
586
|
+
|
|
576
587
|
self._set_command_running(True, f"Running {command} ...")
|
|
577
588
|
self.run_worker(
|
|
578
589
|
self._handle_slash_command_async(command),
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import OptionList, Static
|
|
11
|
+
from textual.widgets.option_list import Option
|
|
12
|
+
|
|
13
|
+
from kiwi_tui.slash_commands import SLASH_COMMANDS
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HelpScreen(ModalScreen[None]):
|
|
17
|
+
"""Modal that lists available slash commands and allows copying."""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
Binding("escape", "close", "Close", show=True),
|
|
21
|
+
Binding("c", "copy", "Copy", show=True),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
CSS = """
|
|
25
|
+
HelpScreen {
|
|
26
|
+
align: center middle;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#help-container {
|
|
30
|
+
width: 96;
|
|
31
|
+
height: 80%;
|
|
32
|
+
background: $surface;
|
|
33
|
+
border: solid $accent;
|
|
34
|
+
/* Reduce top padding so the title/hint sit closer to the border. */
|
|
35
|
+
padding: 0 3 1 3;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#help-title {
|
|
39
|
+
width: 100%;
|
|
40
|
+
text-align: center;
|
|
41
|
+
text-style: bold;
|
|
42
|
+
color: $primary;
|
|
43
|
+
height: 1;
|
|
44
|
+
/* Tighten vertical spacing at the top of the modal. */
|
|
45
|
+
margin-bottom: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#help-hint {
|
|
49
|
+
width: 100%;
|
|
50
|
+
height: 1;
|
|
51
|
+
padding: 0 1;
|
|
52
|
+
color: $text-muted;
|
|
53
|
+
text-style: italic;
|
|
54
|
+
margin-bottom: 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#help-list {
|
|
58
|
+
height: 1fr;
|
|
59
|
+
width: 100%;
|
|
60
|
+
border: solid $panel;
|
|
61
|
+
background: $surface;
|
|
62
|
+
scrollbar-size: 1 1;
|
|
63
|
+
/* Keep the list away from the container border for consistent margins. */
|
|
64
|
+
margin: 0 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#help-list > .option-list--option-highlighted {
|
|
68
|
+
background: $primary-background;
|
|
69
|
+
color: $primary;
|
|
70
|
+
text-style: bold;
|
|
71
|
+
}
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
super().__init__()
|
|
76
|
+
self._last_copy_notify_at: float = 0.0
|
|
77
|
+
self._last_highlight_index: int | None = None
|
|
78
|
+
self._highlight_skip_guard: bool = False
|
|
79
|
+
|
|
80
|
+
def on_mount(self) -> None:
|
|
81
|
+
# Match the UX of the inline @ picker: focus the list and highlight the first command.
|
|
82
|
+
option_list = self.query_one("#help-list", OptionList)
|
|
83
|
+
try:
|
|
84
|
+
for idx, opt in enumerate(option_list.options):
|
|
85
|
+
if not getattr(opt, "disabled", False) and opt.id:
|
|
86
|
+
option_list.highlighted = idx
|
|
87
|
+
break
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
option_list.focus()
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
# Column widths tuned for a typical terminal width; the OptionList will clip if narrower.
|
|
94
|
+
cmd_col_width = 38
|
|
95
|
+
with Vertical(id="help-container"):
|
|
96
|
+
yield Static("Slash Commands", id="help-title")
|
|
97
|
+
yield Static("Enter/click: copy C: copy highlighted Esc: close", id="help-hint")
|
|
98
|
+
|
|
99
|
+
option_list = OptionList(id="help-list")
|
|
100
|
+
|
|
101
|
+
last_category: str | None = None
|
|
102
|
+
for cmd in SLASH_COMMANDS:
|
|
103
|
+
if cmd.category != last_category:
|
|
104
|
+
# Add a blank spacer row between sections for readability.
|
|
105
|
+
if last_category is not None:
|
|
106
|
+
option_list.add_option(Option(Text(""), id=None, disabled=True))
|
|
107
|
+
|
|
108
|
+
last_category = cmd.category
|
|
109
|
+
option_list.add_option(
|
|
110
|
+
Option(Text(f" {cmd.category.upper()}", style="bold cyan"), id=None, disabled=True)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Option id is the command template so we can copy exactly what is shown.
|
|
114
|
+
label = f" {cmd.template:<{cmd_col_width}} {cmd.description}"
|
|
115
|
+
option_list.add_option(Option(Text(label), id=cmd.template))
|
|
116
|
+
|
|
117
|
+
yield option_list
|
|
118
|
+
|
|
119
|
+
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
120
|
+
"""Skip spacer/category rows while navigating with arrow keys."""
|
|
121
|
+
if self._highlight_skip_guard:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Track previous position so we can infer direction.
|
|
125
|
+
prev = self._last_highlight_index
|
|
126
|
+
|
|
127
|
+
# Textual versions differ slightly in attribute names.
|
|
128
|
+
idx = getattr(event, "index", None)
|
|
129
|
+
if idx is None:
|
|
130
|
+
idx = getattr(event, "option_index", None)
|
|
131
|
+
if idx is None:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
self._last_highlight_index = idx
|
|
135
|
+
|
|
136
|
+
option = event.option
|
|
137
|
+
if option.id and not getattr(option, "disabled", False):
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
option_list = event.option_list
|
|
141
|
+
try:
|
|
142
|
+
direction = -1 if (prev is not None and idx < prev) else 1
|
|
143
|
+
n = len(option_list.options)
|
|
144
|
+
self._highlight_skip_guard = True
|
|
145
|
+
cur = idx
|
|
146
|
+
while 0 <= cur < n:
|
|
147
|
+
cur += direction
|
|
148
|
+
if not (0 <= cur < n):
|
|
149
|
+
break
|
|
150
|
+
opt = option_list.options[cur]
|
|
151
|
+
if opt.id and not getattr(opt, "disabled", False):
|
|
152
|
+
option_list.highlighted = cur
|
|
153
|
+
self._last_highlight_index = cur
|
|
154
|
+
break
|
|
155
|
+
finally:
|
|
156
|
+
self._highlight_skip_guard = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _notify_once(self, msg: str, *, severity: str) -> None:
|
|
161
|
+
now = time.monotonic()
|
|
162
|
+
if now - self._last_copy_notify_at < 1.0:
|
|
163
|
+
return
|
|
164
|
+
self._last_copy_notify_at = now
|
|
165
|
+
self.app.notify(msg, severity=severity)
|
|
166
|
+
|
|
167
|
+
def _copy_to_clipboard(self, text: str) -> bool:
|
|
168
|
+
"""Copy text to the system clipboard. Returns True if it likely worked."""
|
|
169
|
+
copied = False
|
|
170
|
+
|
|
171
|
+
# Prefer native OS clipboard tools (more reliable in terminal apps).
|
|
172
|
+
try:
|
|
173
|
+
if sys.platform == "darwin":
|
|
174
|
+
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
|
|
175
|
+
copied = True
|
|
176
|
+
elif sys.platform.startswith("linux"):
|
|
177
|
+
subprocess.run(
|
|
178
|
+
["xclip", "-selection", "clipboard"],
|
|
179
|
+
input=text.encode("utf-8"),
|
|
180
|
+
check=True,
|
|
181
|
+
)
|
|
182
|
+
copied = True
|
|
183
|
+
elif sys.platform.startswith("win"):
|
|
184
|
+
subprocess.run(["clip"], input=text.encode("utf-8"), check=True)
|
|
185
|
+
copied = True
|
|
186
|
+
except Exception:
|
|
187
|
+
copied = False
|
|
188
|
+
|
|
189
|
+
# Fallback to Textual's clipboard integration.
|
|
190
|
+
if not copied:
|
|
191
|
+
try:
|
|
192
|
+
self.app.copy_to_clipboard(text)
|
|
193
|
+
copied = True
|
|
194
|
+
except Exception:
|
|
195
|
+
copied = False
|
|
196
|
+
|
|
197
|
+
return copied
|
|
198
|
+
|
|
199
|
+
def _copy_selected(self, template: str) -> None:
|
|
200
|
+
if not template.strip():
|
|
201
|
+
return
|
|
202
|
+
if self._copy_to_clipboard(template):
|
|
203
|
+
self._notify_once(f"Copied: {template}", severity="information")
|
|
204
|
+
else:
|
|
205
|
+
self._notify_once("Failed to copy to clipboard", severity="error")
|
|
206
|
+
|
|
207
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
208
|
+
template = event.option.id
|
|
209
|
+
if template:
|
|
210
|
+
self._copy_selected(str(template))
|
|
211
|
+
|
|
212
|
+
def action_copy(self) -> None:
|
|
213
|
+
option_list = self.query_one("#help-list", OptionList)
|
|
214
|
+
opt = option_list.highlighted_option
|
|
215
|
+
if opt and opt.id:
|
|
216
|
+
self._copy_selected(str(opt.id))
|
|
217
|
+
|
|
218
|
+
def action_close(self) -> None:
|
|
219
|
+
self.dismiss(None)
|
|
@@ -17,12 +17,30 @@ from textual.screen import Screen
|
|
|
17
17
|
from textual.widgets import Footer, Header, RichLog, Static
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def _tail_lines(path: Path, max_lines: int = 3000) -> list[str]:
|
|
21
|
-
"""Read the last N lines of a file without loading the whole file.
|
|
20
|
+
def _tail_lines(path: Path, max_lines: int = 3000, max_bytes: int = 30 * 1024) -> list[str]:
|
|
21
|
+
"""Read the last N lines of a file without loading the whole file.
|
|
22
|
+
|
|
23
|
+
We read only a tail window (``max_bytes``) so opening the logs screen doesn't
|
|
24
|
+
allocate RAM proportional to the on-disk log size.
|
|
25
|
+
"""
|
|
26
|
+
if max_bytes <= 0:
|
|
27
|
+
return []
|
|
28
|
+
|
|
22
29
|
try:
|
|
23
|
-
|
|
30
|
+
with open(path, "rb") as fp:
|
|
31
|
+
fp.seek(0, 2)
|
|
32
|
+
size = fp.tell()
|
|
33
|
+
fp.seek(max(0, size - max_bytes), 0)
|
|
34
|
+
data = fp.read(max_bytes)
|
|
24
35
|
except OSError:
|
|
25
36
|
return []
|
|
37
|
+
|
|
38
|
+
# If we started mid-file, drop the partial first line.
|
|
39
|
+
if size > max_bytes:
|
|
40
|
+
nl = data.find(b"\n")
|
|
41
|
+
if 0 <= nl < len(data) - 1:
|
|
42
|
+
data = data[nl + 1 :]
|
|
43
|
+
|
|
26
44
|
lines = data.splitlines()[-max_lines:]
|
|
27
45
|
out: list[str] = []
|
|
28
46
|
for b in lines:
|
|
@@ -120,6 +138,15 @@ class RuntimeLogsScreen(Screen):
|
|
|
120
138
|
except Exception:
|
|
121
139
|
return
|
|
122
140
|
if not chunk:
|
|
141
|
+
# If the runtime log is being trimmed in-place, the file can shrink.
|
|
142
|
+
# When that happens our read cursor may point past EOF; re-seek to the
|
|
143
|
+
# new end so streaming continues.
|
|
144
|
+
try:
|
|
145
|
+
if self._log_path and self._log_path.exists():
|
|
146
|
+
if self._log_path.stat().st_size < self._fp.tell():
|
|
147
|
+
self._fp.seek(0, 2)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
123
150
|
return
|
|
124
151
|
|
|
125
152
|
try:
|
|
@@ -1,45 +1,26 @@
|
|
|
1
1
|
"""Slash command picker modal screen."""
|
|
2
2
|
|
|
3
3
|
from textual.app import ComposeResult
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual.containers import Vertical
|
|
4
6
|
from textual.screen import ModalScreen
|
|
5
|
-
from textual.widgets import
|
|
7
|
+
from textual.widgets import OptionList, Static
|
|
6
8
|
from textual.widgets.option_list import Option
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
+
|
|
10
|
+
from kiwi_tui.slash_commands import SLASH_COMMANDS
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
# Commands and their descriptions
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
("/new", "Start new conversation"),
|
|
15
|
-
("/status", "Show current action & run"),
|
|
16
|
-
("/cancel", "Cancel active request"),
|
|
17
|
-
("/actions list", "List recent actions"),
|
|
18
|
-
("/runs list", "List recent runs"),
|
|
19
|
-
("/graphs list", "List recent graphs"),
|
|
20
|
-
("/graph-runs list", "List recent graph runs"),
|
|
21
|
-
("/use ", "Switch action by ID"),
|
|
22
|
-
("/continue ", "Continue a run by ID"),
|
|
23
|
-
("/upload ", "Upload file(s) by path"),
|
|
24
|
-
("/files", "Show pending files"),
|
|
25
|
-
("/clear-files", "Clear pending files"),
|
|
26
|
-
("/login", "Log in"),
|
|
27
|
-
("/logout", "Log out"),
|
|
28
|
-
("/metadata", "Show metadata"),
|
|
29
|
-
("/metadata set ", "Set metadata key"),
|
|
30
|
-
("/metadata remove ", "Remove metadata key"),
|
|
31
|
-
("/metadata clear", "Clear all metadata"),
|
|
32
|
-
("/connect-cli", "Tell the agent to connect to the local CLI"),
|
|
33
|
-
("/show-logs", "Show local CLI (runtime) logs"),
|
|
34
|
-
]
|
|
13
|
+
# Commands and their descriptions (single source of truth).
|
|
14
|
+
# We show the template in the picker, and return insert_text on selection.
|
|
15
|
+
_COMMANDS = [(c.insert_text, c.template, c.description) for c in SLASH_COMMANDS]
|
|
35
16
|
|
|
36
17
|
|
|
37
18
|
class SlashPickerScreen(ModalScreen[str]):
|
|
38
19
|
"""Modal that lets the user pick a slash command.
|
|
39
20
|
|
|
40
|
-
Returns the command string, or empty string if cancelled.
|
|
41
|
-
Commands that don't need arguments
|
|
42
|
-
|
|
21
|
+
Returns the command string (insert_text), or empty string if cancelled.
|
|
22
|
+
Commands that don't need arguments are returned as-is for immediate execution.
|
|
23
|
+
Commands that expect args end with a trailing space ("/use ").
|
|
43
24
|
"""
|
|
44
25
|
|
|
45
26
|
BINDINGS = [
|
|
@@ -79,8 +60,8 @@ class SlashPickerScreen(ModalScreen[str]):
|
|
|
79
60
|
with Vertical(id="slash-container"):
|
|
80
61
|
yield Static("Select a command", id="slash-title")
|
|
81
62
|
option_list = OptionList(id="slash-list")
|
|
82
|
-
for
|
|
83
|
-
option_list.add_option(Option(f"{
|
|
63
|
+
for insert_text, template, desc in _COMMANDS:
|
|
64
|
+
option_list.add_option(Option(f"{template:<32} {desc}", id=insert_text))
|
|
84
65
|
yield option_list
|
|
85
66
|
|
|
86
67
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
@dataclass(frozen=True)
|
|
4
|
+
class SlashCommand:
|
|
5
|
+
category: str
|
|
6
|
+
template: str
|
|
7
|
+
description: str
|
|
8
|
+
insert_text: str
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def needs_args(self) -> bool:
|
|
12
|
+
return self.insert_text.endswith(" ")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# NOTE: Keep this aligned with DashboardScreen's TUI command handler.
|
|
16
|
+
SLASH_COMMANDS: list[SlashCommand] = [
|
|
17
|
+
# Session
|
|
18
|
+
SlashCommand("Session", "/help", "Show all slash commands", "/help"),
|
|
19
|
+
SlashCommand("Session", "/new", "Start new conversation", "/new"),
|
|
20
|
+
SlashCommand("Session", "/status", "Show current action & run", "/status"),
|
|
21
|
+
SlashCommand("Session", "/cancel", "Cancel active request", "/cancel"),
|
|
22
|
+
SlashCommand("Session", "/use <action_id>", "Switch to a different action", "/use "),
|
|
23
|
+
SlashCommand("Session", "/continue <run_id>", "Continue an existing run", "/continue "),
|
|
24
|
+
|
|
25
|
+
# Files
|
|
26
|
+
SlashCommand("Files", "/upload <path> [path2 ...]", "Upload file(s) and attach to next message", "/upload "),
|
|
27
|
+
SlashCommand("Files", "/files", "Show pending attachments", "/files"),
|
|
28
|
+
SlashCommand("Files", "/clear-files", "Clear pending attachments", "/clear-files"),
|
|
29
|
+
|
|
30
|
+
# Auth
|
|
31
|
+
SlashCommand("Auth", "/login", "Log in", "/login"),
|
|
32
|
+
SlashCommand("Auth", "/logout", "Log out", "/logout"),
|
|
33
|
+
|
|
34
|
+
# Metadata
|
|
35
|
+
SlashCommand("Metadata", "/metadata", "Show effective metadata", "/metadata"),
|
|
36
|
+
SlashCommand("Metadata", "/metadata set <key> <value>", "Set a metadata field", "/metadata set "),
|
|
37
|
+
SlashCommand("Metadata", "/metadata remove <key>", "Remove a metadata field", "/metadata remove "),
|
|
38
|
+
SlashCommand("Metadata", "/metadata clear", "Clear all metadata overrides", "/metadata clear"),
|
|
39
|
+
|
|
40
|
+
# Runtime (local CLI agent)
|
|
41
|
+
SlashCommand("Runtime", "/connect-cli", "Tell the agent to connect to the local CLI", "/connect-cli"),
|
|
42
|
+
SlashCommand("Runtime", "/show-logs", "Show local CLI (runtime) logs", "/show-logs"),
|
|
43
|
+
SlashCommand("Runtime", "/runtime", "Runtime commands (currently disabled)", "/runtime"),
|
|
44
|
+
|
|
45
|
+
# Query (API)
|
|
46
|
+
SlashCommand("Query", "/actions list", "List recent actions", "/actions list"),
|
|
47
|
+
SlashCommand("Query", "/actions get <id>", "Get an action by id", "/actions get "),
|
|
48
|
+
SlashCommand("Query", "/runs list", "List recent runs", "/runs list"),
|
|
49
|
+
SlashCommand("Query", "/runs get <id>", "Get a run by id", "/runs get "),
|
|
50
|
+
SlashCommand("Query", "/graphs list", "List recent graphs", "/graphs list"),
|
|
51
|
+
SlashCommand("Query", "/graphs get <id>", "Get a graph by id", "/graphs get "),
|
|
52
|
+
SlashCommand("Query", "/graph-runs list", "List recent graph runs", "/graph-runs list"),
|
|
53
|
+
SlashCommand("Query", "/graph-runs get <id>", "Get a graph run by id", "/graph-runs get "),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def autocomplete_commands() -> list[str]:
|
|
58
|
+
"""Return strings used for chat input autocomplete."""
|
|
59
|
+
# We keep the insert_text version for consistency with the actual command parser.
|
|
60
|
+
return [c.insert_text for c in SLASH_COMMANDS]
|
|
@@ -12,34 +12,10 @@ from textual.widgets._text_area import TextArea
|
|
|
12
12
|
from rich.text import Text
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
# Slash commands for autocomplete
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"/actions get ",
|
|
20
|
-
"/runs list",
|
|
21
|
-
"/runs get ",
|
|
22
|
-
"/graphs list",
|
|
23
|
-
"/graphs get ",
|
|
24
|
-
"/graph-runs list",
|
|
25
|
-
"/graph-runs get ",
|
|
26
|
-
"/use ",
|
|
27
|
-
"/continue ",
|
|
28
|
-
"/new",
|
|
29
|
-
"/status",
|
|
30
|
-
"/cancel",
|
|
31
|
-
"/upload ",
|
|
32
|
-
"/files",
|
|
33
|
-
"/clear-files",
|
|
34
|
-
"/login",
|
|
35
|
-
"/logout",
|
|
36
|
-
"/metadata",
|
|
37
|
-
"/metadata set ",
|
|
38
|
-
"/metadata remove ",
|
|
39
|
-
"/metadata clear",
|
|
40
|
-
"/connect-cli",
|
|
41
|
-
"/show-logs",
|
|
42
|
-
]
|
|
15
|
+
# Slash commands for autocomplete (single source of truth)
|
|
16
|
+
from kiwi_tui.slash_commands import autocomplete_commands
|
|
17
|
+
|
|
18
|
+
_SLASH_COMMANDS = autocomplete_commands()
|
|
43
19
|
|
|
44
20
|
|
|
45
21
|
class ChatInput(TextArea):
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kiwi_tui.runtime_agent import MAX_RUNTIME_LOG_BYTES, _trim_file_to_tail_bytes
|
|
8
|
+
from kiwi_tui.screens.runtime_logs import _tail_lines
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _make_log_bytes(num_lines: int) -> bytes:
|
|
12
|
+
# Fixed-width lines make expected tails deterministic.
|
|
13
|
+
return b"".join([f"line-{i:06d} hello world\n".encode("utf-8") for i in range(num_lines)])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_trim_file_to_tail_bytes_keeps_last_bytes(tmp_path: Path) -> None:
|
|
17
|
+
p = tmp_path / "log"
|
|
18
|
+
payload = _make_log_bytes(20000) # plenty bigger than 30KB
|
|
19
|
+
p.write_bytes(payload)
|
|
20
|
+
|
|
21
|
+
_trim_file_to_tail_bytes(p, max_bytes=MAX_RUNTIME_LOG_BYTES)
|
|
22
|
+
|
|
23
|
+
data = p.read_bytes()
|
|
24
|
+
assert len(data) <= MAX_RUNTIME_LOG_BYTES
|
|
25
|
+
|
|
26
|
+
# We should keep the most recent line.
|
|
27
|
+
assert b"line-019999" in data
|
|
28
|
+
|
|
29
|
+
# We should *not* keep the earliest line.
|
|
30
|
+
assert b"line-000000" not in data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_tail_lines_reads_tail_window(tmp_path: Path) -> None:
|
|
34
|
+
p = tmp_path / "log"
|
|
35
|
+
payload = _make_log_bytes(5000)
|
|
36
|
+
p.write_bytes(payload)
|
|
37
|
+
|
|
38
|
+
# Read a small tail window and last few lines from it.
|
|
39
|
+
lines = _tail_lines(p, max_lines=5, max_bytes=512)
|
|
40
|
+
assert len(lines) == 5
|
|
41
|
+
|
|
42
|
+
# Must include the final log line.
|
|
43
|
+
assert lines[-1].startswith("line-004999")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|