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.
Files changed (50) hide show
  1. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/runtime_agent.py +119 -0
  4. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/dashboard.py +11 -0
  5. kiwi_code-0.0.26/src/kiwi_tui/screens/help.py +219 -0
  6. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/runtime_logs.py +30 -3
  7. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/slash_picker.py +13 -32
  8. kiwi_code-0.0.26/src/kiwi_tui/slash_commands.py +60 -0
  9. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/widgets.py +4 -28
  10. kiwi_code-0.0.26/tests/test_runtime_log_trimming.py +43 -0
  11. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/uv.lock +1 -1
  12. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/.github/workflows/publish.yml +0 -0
  13. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/.github/workflows/test.yml +0 -0
  14. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/.gitignore +0 -0
  15. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/.python-version +0 -0
  16. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/CLAUDE.md +0 -0
  17. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/Makefile +0 -0
  18. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/README.md +0 -0
  19. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/__init__.py +0 -0
  20. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/auth.py +0 -0
  21. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/cli.py +0 -0
  22. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/client.py +0 -0
  23. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/commands.py +0 -0
  24. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/logger.py +0 -0
  25. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/models.py +0 -0
  26. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/runtime_manager.py +0 -0
  27. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_cli/server.py +0 -0
  28. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/__init__.py +0 -0
  29. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/__main__.py +0 -0
  30. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/main.py +0 -0
  31. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  32. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  33. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/__init__.py +0 -0
  34. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/inline_file_picker.py +0 -0
  35. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/main.py +0 -0
  36. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/__init__.py +0 -0
  37. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/attach_content.py +0 -0
  38. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/command_result.py +0 -0
  39. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/file_browser.py +0 -0
  40. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/id_picker.py +0 -0
  41. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/login.py +0 -0
  42. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  43. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/test_hello.py +0 -0
  44. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/__init__.py +0 -0
  45. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/conftest.py +0 -0
  46. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_cli_help.py +0 -0
  47. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_imports.py +0 -0
  48. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_reexec_kiwi.py +0 -0
  49. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_tokens.py +0 -0
  50. {kiwi_code-0.0.24 → kiwi_code-0.0.26}/tests/test_tui_headless.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.24
3
+ Version: 0.0.26
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.24"
3
+ version = "0.0.26"
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"
@@ -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
- data = path.read_bytes()
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 Static, Button, OptionList
7
+ from textual.widgets import OptionList, Static
6
8
  from textual.widgets.option_list import Option
7
- from textual.containers import Vertical
8
- from textual.binding import Binding
9
+
10
+ from kiwi_tui.slash_commands import SLASH_COMMANDS
9
11
 
10
12
 
11
- # Commands and their descriptions
12
- _COMMANDS = [
13
- ("/help", "Show help"),
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 (no trailing space) are returned as-is
42
- for immediate execution.
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 cmd, desc in _COMMANDS:
83
- option_list.add_option(Option(f"{cmd:<25} {desc}", id=cmd))
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
- _SLASH_COMMANDS = [
17
- "/help",
18
- "/actions list",
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")
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.24"
400
+ version = "0.0.26"
401
401
  source = { editable = "." }
402
402
  dependencies = [
403
403
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes