kiwi-code 0.0.6__tar.gz → 0.0.8__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 (33) hide show
  1. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/PKG-INFO +2 -1
  2. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/pyproject.toml +2 -1
  3. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/cli.py +2 -0
  4. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_runtime/main.py +264 -6
  5. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/main.py +4 -3
  6. kiwi_code-0.0.8/src/kiwi_tui/screens/__init__.py +7 -0
  7. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/screens/dashboard.py +22 -7
  8. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/widgets.py +93 -1
  9. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/uv.lock +618 -567
  10. kiwi_code-0.0.6/src/kiwi_tui/screens/__init__.py +0 -9
  11. kiwi_code-0.0.6/src/kiwi_tui/screens/actions.py +0 -271
  12. kiwi_code-0.0.6/src/kiwi_tui/screens/autobots.py +0 -216
  13. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/.github/workflows/publish.yml +0 -0
  14. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/.gitignore +0 -0
  15. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/.python-version +0 -0
  16. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/CLAUDE.md +0 -0
  17. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/Makefile +0 -0
  18. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/README.md +0 -0
  19. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/__init__.py +0 -0
  20. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/auth.py +0 -0
  21. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/client.py +0 -0
  22. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/commands.py +0 -0
  23. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/config.py +0 -0
  24. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/logger.py +0 -0
  25. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/models.py +0 -0
  26. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/runtime_manager.py +0 -0
  27. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_runtime/__init__.py +0 -0
  28. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_runtime/__main__.py +0 -0
  29. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/__init__.py +0 -0
  30. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/screens/file_browser.py +0 -0
  31. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/screens/login.py +0 -0
  32. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  33. {kiwi_code-0.0.6 → kiwi_code-0.0.8}/test_hello.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.6
3
+ Version: 0.0.8
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
@@ -18,6 +18,7 @@ Requires-Dist: httpx>=0.25.0
18
18
  Requires-Dist: loguru>=0.7.3
19
19
  Requires-Dist: psutil>=5.9.0
20
20
  Requires-Dist: pydantic>=2.12.5
21
+ Requires-Dist: setproctitle>=1.3.0
21
22
  Requires-Dist: textual-dev>=1.8.0
22
23
  Requires-Dist: textual>=8.1.1
23
24
  Requires-Dist: typer>=0.24.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.6"
3
+ version = "0.0.8"
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.13,<4.0"
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "websockets>=14.1",
15
15
  "httpx>=0.25.0",
16
16
  "psutil>=5.9.0",
17
+ "setproctitle>=1.3.0",
17
18
  ]
18
19
  authors = [
19
20
  { name = "Anurag Jha", email = "anurag@meetkiwi.co" }
@@ -237,6 +237,8 @@ def tui():
237
237
 
238
238
  def cli():
239
239
  """Entry point for the CLI."""
240
+ import setproctitle
241
+ setproctitle.setproctitle("kiwicli")
240
242
  app()
241
243
 
242
244
  if __name__ == "__main__":
@@ -610,6 +610,229 @@ if not IS_WINDOWS:
610
610
  self.slave_fd = None
611
611
 
612
612
 
613
+ class PersistentShell:
614
+ """A long-lived bash PTY that persists across commands.
615
+
616
+ All one-shot ``command`` messages are routed through the same shell so
617
+ that ``cd``, environment variables, virtualenv activations, aliases, and
618
+ other shell state carry over between commands. The server still
619
+ receives ``{stdout, stderr, exit_code}`` — no backend changes needed.
620
+ """
621
+
622
+ _SHELL_SENTINEL = "__KIWI_SHELL_DONE__"
623
+
624
+ def __init__(self, cwd: str | None = None, cols: int = 200, rows: int = 50):
625
+ self._cwd = cwd
626
+ self._cols = cols
627
+ self._rows = rows
628
+ self.master_fd: int | None = None
629
+ self._pid: int | None = None
630
+
631
+ # -- lifecycle --------------------------------------------------------
632
+
633
+ def start(self) -> None:
634
+ """Spawn a bash PTY that stays alive for the runtime's lifetime."""
635
+ master_fd, slave_fd = pty.openpty()
636
+
637
+ # Set terminal size
638
+ winsize = struct.pack("HHHH", self._rows, self._cols, 0, 0)
639
+ fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
640
+
641
+ pid = os.fork()
642
+ if pid == 0:
643
+ # Child — become session leader and exec bash
644
+ os.setsid()
645
+ os.dup2(slave_fd, 0)
646
+ os.dup2(slave_fd, 1)
647
+ os.dup2(slave_fd, 2)
648
+ if slave_fd > 2:
649
+ os.close(slave_fd)
650
+ os.close(master_fd)
651
+ if self._cwd:
652
+ os.chdir(self._cwd)
653
+ os.execvp("bash", [
654
+ "bash", "--norc", "--noprofile", "-i",
655
+ ])
656
+ else:
657
+ # Parent
658
+ os.close(slave_fd)
659
+ self.master_fd = master_fd
660
+ self._pid = pid
661
+
662
+ # Set non-blocking reads
663
+ flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
664
+ fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
665
+
666
+ # Wait for bash to be ready, drain the initial prompt
667
+ import time
668
+ time.sleep(0.2)
669
+ self._drain()
670
+
671
+ # Disable echo and set a blank prompt so output is clean
672
+ self._write("stty -echo\n")
673
+ time.sleep(0.1)
674
+ self._drain()
675
+ self._write("PS1=''; PS2=''\n")
676
+ time.sleep(0.1)
677
+ self._drain()
678
+
679
+ print_status(">", "Persistent shell started (bash PTY)", GREEN)
680
+
681
+ def is_alive(self) -> bool:
682
+ """Check whether the shell process is still running."""
683
+ if self._pid is None:
684
+ return False
685
+ try:
686
+ pid, status = os.waitpid(self._pid, os.WNOHANG)
687
+ if pid != 0:
688
+ self._pid = None
689
+ return False
690
+ return True
691
+ except ChildProcessError:
692
+ self._pid = None
693
+ return False
694
+
695
+ def close(self) -> None:
696
+ """Terminate the persistent shell."""
697
+ if self.master_fd is not None:
698
+ try:
699
+ os.write(self.master_fd, b"exit\n")
700
+ except OSError:
701
+ pass
702
+ try:
703
+ os.close(self.master_fd)
704
+ except OSError:
705
+ pass
706
+ self.master_fd = None
707
+ if self._pid is not None:
708
+ try:
709
+ os.kill(self._pid, signal.SIGTERM)
710
+ os.waitpid(self._pid, 0)
711
+ except (ProcessLookupError, ChildProcessError):
712
+ pass
713
+ self._pid = None
714
+
715
+ # -- I/O helpers ------------------------------------------------------
716
+
717
+ def _write(self, data: str) -> None:
718
+ if self.master_fd is not None:
719
+ os.write(self.master_fd, data.encode("utf-8"))
720
+
721
+ def _drain(self) -> str:
722
+ """Read all currently available data from the PTY (non-blocking)."""
723
+ import select
724
+ chunks: list[str] = []
725
+ while True:
726
+ ready, _, _ = select.select([self.master_fd], [], [], 0.05)
727
+ if not ready:
728
+ break
729
+ try:
730
+ data = os.read(self.master_fd, 65536)
731
+ if not data:
732
+ break
733
+ chunks.append(data.decode("utf-8", errors="replace"))
734
+ except OSError:
735
+ break
736
+ return "".join(chunks)
737
+
738
+ # -- command execution ------------------------------------------------
739
+
740
+ async def run_command(self, command: str, timeout: int = 120) -> dict:
741
+ """Run a command in the persistent shell, returning {stdout, stderr, exit_code}.
742
+
743
+ Uses a sentinel pattern to detect when the command has finished.
744
+ """
745
+ if not self.is_alive():
746
+ return {"stdout": "", "stderr": "Persistent shell is not running", "exit_code": -1}
747
+
748
+ sentinel = self._SHELL_SENTINEL
749
+ # Wrap the command: run it, then echo sentinel+exit_code+sentinel
750
+ wrapped = (
751
+ f"{command}\n"
752
+ f"__kiwi_ec=$?; echo \"{sentinel}${{__kiwi_ec}}{sentinel}\"; "
753
+ f"echo \"{sentinel}${{__kiwi_ec}}{sentinel}\" >&2\n"
754
+ )
755
+
756
+ # Drain any leftover output
757
+ self._drain()
758
+
759
+ # Send the command
760
+ self._write(wrapped)
761
+
762
+ # Read output until we see the sentinel
763
+ import select
764
+ collected: list[str] = []
765
+ loop = asyncio.get_event_loop()
766
+
767
+ try:
768
+ output = await asyncio.wait_for(
769
+ loop.run_in_executor(None, self._read_until_sentinel, sentinel, timeout),
770
+ timeout=timeout + 5,
771
+ )
772
+ except (asyncio.TimeoutError, TimeoutError):
773
+ return {"stdout": "", "stderr": f"Command timed out after {timeout}s", "exit_code": -1}
774
+
775
+ return output
776
+
777
+ def _read_until_sentinel(self, sentinel: str, timeout: int) -> dict:
778
+ """Blocking read from PTY until sentinel is found. Returns parsed result."""
779
+ import select
780
+ import time
781
+
782
+ deadline = time.monotonic() + timeout
783
+ buf = ""
784
+
785
+ while time.monotonic() < deadline:
786
+ remaining = deadline - time.monotonic()
787
+ if remaining <= 0:
788
+ break
789
+
790
+ ready, _, _ = select.select(
791
+ [self.master_fd], [], [], min(0.1, remaining)
792
+ )
793
+ if ready:
794
+ try:
795
+ data = os.read(self.master_fd, 65536)
796
+ if not data:
797
+ break
798
+ buf += data.decode("utf-8", errors="replace")
799
+ except OSError:
800
+ break
801
+
802
+ # Check if sentinel appeared
803
+ sentinel_marker = sentinel
804
+ # We look for the pattern: sentinel + exit_code + sentinel
805
+ import re
806
+ pattern = re.escape(sentinel) + r"(\d+)" + re.escape(sentinel)
807
+ match = re.search(pattern, buf)
808
+ if match:
809
+ exit_code = int(match.group(1))
810
+ # Everything before the sentinel line is stdout
811
+ stdout = buf[:match.start()]
812
+ # Clean up: remove the sentinel echo command itself if visible
813
+ lines = stdout.splitlines(keepends=True)
814
+ clean_lines = []
815
+ for line in lines:
816
+ stripped = line.strip()
817
+ if sentinel in stripped:
818
+ continue
819
+ if stripped.startswith("__kiwi_ec="):
820
+ continue
821
+ clean_lines.append(line)
822
+ stdout = "".join(clean_lines).strip()
823
+
824
+ # Truncate if needed
825
+ stdout = stdout[:MAX_OUTPUT_BYTES]
826
+
827
+ return {
828
+ "stdout": stdout,
829
+ "stderr": "",
830
+ "exit_code": exit_code,
831
+ }
832
+
833
+ return {"stdout": buf[:MAX_OUTPUT_BYTES], "stderr": "Command timed out", "exit_code": -1}
834
+
835
+
613
836
  class PipeProcess:
614
837
  """Manages a persistent shell session using subprocess pipes (Windows-compatible)."""
615
838
 
@@ -777,6 +1000,7 @@ async def connect(
777
1000
  token: str,
778
1001
  mode: str = "restricted",
779
1002
  allowed_dirs: list[str] | None = None,
1003
+ shell: "PersistentShell | None" = None,
780
1004
  ) -> str:
781
1005
  """Connect to the server WebSocket and process commands.
782
1006
 
@@ -831,11 +1055,26 @@ async def connect(
831
1055
  command = msg.get("command", "")
832
1056
  print_cmd_log(request_id, f"$ {BOLD}{command}{RESET}")
833
1057
 
834
- result = await run_command(
835
- command,
836
- mode=mode,
837
- allowed_dirs=allowed_dirs,
838
- )
1058
+ # Validate in restricted mode before execution
1059
+ if mode == "restricted" and allowed_dirs:
1060
+ ok, reason = validate_command(
1061
+ command, allowed_dirs,
1062
+ pid=shell._pid if shell and shell.is_alive() else None,
1063
+ )
1064
+ if not ok:
1065
+ result = {"stdout": "", "stderr": reason, "exit_code": 1}
1066
+ elif shell and shell.is_alive():
1067
+ result = await shell.run_command(command)
1068
+ else:
1069
+ result = await run_command(
1070
+ command, mode=mode, allowed_dirs=allowed_dirs,
1071
+ )
1072
+ elif shell and shell.is_alive():
1073
+ result = await shell.run_command(command)
1074
+ else:
1075
+ result = await run_command(
1076
+ command, mode=mode, allowed_dirs=allowed_dirs,
1077
+ )
839
1078
  exit_code = result["exit_code"]
840
1079
  success = exit_code == 0
841
1080
  status_text = (
@@ -954,6 +1193,9 @@ async def connect(
954
1193
 
955
1194
 
956
1195
  def main():
1196
+ import setproctitle
1197
+ setproctitle.setproctitle("kiwi-runtime")
1198
+
957
1199
  parser = argparse.ArgumentParser(
958
1200
  description="Kiwi AI CLI Agent — execute terminal commands for LLM agents"
959
1201
  )
@@ -1084,6 +1326,18 @@ def main():
1084
1326
  loop.close()
1085
1327
  sys.exit(1)
1086
1328
 
1329
+ # Start persistent shell (Unix only) — survives reconnections
1330
+ persistent_shell = None
1331
+ if not IS_WINDOWS:
1332
+ print_section("Shell")
1333
+ try:
1334
+ cwd = allowed_dirs[0] if allowed_dirs else os.getcwd()
1335
+ persistent_shell = PersistentShell(cwd=cwd)
1336
+ persistent_shell.start()
1337
+ except Exception as e:
1338
+ print_status("!", f"Persistent shell failed, using one-shot mode: {e}", YELLOW)
1339
+ persistent_shell = None
1340
+
1087
1341
  print_section("Connection")
1088
1342
 
1089
1343
  _shutting_down = False
@@ -1115,7 +1369,7 @@ def main():
1115
1369
 
1116
1370
  try:
1117
1371
  status = loop.run_until_complete(
1118
- connect(ws_url, token, mode=mode, allowed_dirs=allowed_dirs)
1372
+ connect(ws_url, token, mode=mode, allowed_dirs=allowed_dirs, shell=persistent_shell)
1119
1373
  )
1120
1374
  except asyncio.CancelledError:
1121
1375
  break
@@ -1157,6 +1411,10 @@ def main():
1157
1411
  break
1158
1412
  backoff = min(backoff * 2, max_backoff)
1159
1413
 
1414
+ # Clean up persistent shell
1415
+ if persistent_shell:
1416
+ persistent_shell.close()
1417
+
1160
1418
  loop.close()
1161
1419
  print()
1162
1420
  print(f" {GREY}{'─' * 50}{RESET}")
@@ -13,7 +13,7 @@ from kiwi_cli.config import ConfigManager
13
13
  from kiwi_cli.client import AutobotsClientWrapper
14
14
  from kiwi_cli.auth import TokenManager
15
15
  from kiwi_cli import runtime_manager
16
- from .screens import LoginScreen, DashboardScreen, AutobotsScreen, ActionsScreen, RuntimeLogsScreen
16
+ from .screens import LoginScreen, DashboardScreen, RuntimeLogsScreen
17
17
 
18
18
 
19
19
  class AutobotsTUI(App):
@@ -45,8 +45,6 @@ class AutobotsTUI(App):
45
45
  SCREENS = {
46
46
  "login": LoginScreen,
47
47
  "dashboard": DashboardScreen,
48
- "autobots": AutobotsScreen,
49
- "actions": ActionsScreen,
50
48
  "runtime_logs": RuntimeLogsScreen,
51
49
  }
52
50
 
@@ -420,6 +418,9 @@ def _serve_tui(port: int = 8566):
420
418
 
421
419
  def main():
422
420
  """Entry point for the kiwi command."""
421
+ import setproctitle
422
+ setproctitle.setproctitle("kiwi")
423
+
423
424
  import argparse
424
425
 
425
426
  parser = argparse.ArgumentParser(prog="kiwi", description="Kiwi Code TUI")
@@ -0,0 +1,7 @@
1
+ """Screens for Autobots TUI."""
2
+
3
+ from .login import LoginScreen
4
+ from .dashboard import DashboardScreen
5
+ from .runtime_logs import RuntimeLogsScreen
6
+
7
+ __all__ = ["LoginScreen", "DashboardScreen", "RuntimeLogsScreen"]
@@ -5,6 +5,7 @@ from textual.screen import Screen
5
5
  from textual.widgets import Header, Footer, Input, Static, Button
6
6
  from textual.containers import Vertical, VerticalScroll, Horizontal
7
7
  from kiwi_tui.screens.file_browser import FileBrowserScreen
8
+ from kiwi_tui.widgets import ChatInput
8
9
  from textual.worker import Worker, WorkerState
9
10
  from loguru import logger
10
11
  import json
@@ -166,14 +167,14 @@ class DashboardScreen(Screen):
166
167
  yield Static("", id="pending-files-bar")
167
168
  with Horizontal(id="input-row"):
168
169
  yield Button("+", id="upload-btn", variant="default")
169
- yield Input(placeholder="Message...", id="chat-input")
170
+ yield ChatInput(placeholder="Message...", id="chat-input")
170
171
  yield Button("Send", id="send-btn", variant="default")
171
172
 
172
173
  yield Footer()
173
174
 
174
175
  def on_mount(self) -> None:
175
176
  """Called when screen is mounted."""
176
- self.query_one("#chat-input", Input).focus()
177
+ self.query_one("#chat-input", ChatInput).focus()
177
178
 
178
179
  def on_input_submitted(self, event: Input.Submitted) -> None:
179
180
  """Handle message submission."""
@@ -181,7 +182,9 @@ class DashboardScreen(Screen):
181
182
  if not message:
182
183
  return
183
184
 
184
- # Clear input immediately for responsive feel
185
+ # Record in history and clear input
186
+ chat_input = self.query_one("#chat-input", ChatInput)
187
+ chat_input.record(message)
185
188
  event.input.value = ""
186
189
 
187
190
  # Handle "/" commands
@@ -236,6 +239,17 @@ class DashboardScreen(Screen):
236
239
  self.add_message(f"Cleared {count} pending file(s).", "info")
237
240
  return
238
241
 
242
+ if cmd == "/login":
243
+ if self.app.token_manager.is_authenticated():
244
+ self.add_message("Already logged in. Use /logout first to switch accounts.", "info")
245
+ return
246
+ self.app.push_screen("login")
247
+ return
248
+
249
+ if cmd == "/logout":
250
+ self.app.action_logout()
251
+ return
252
+
239
253
  if cmd == "/metadata":
240
254
  if not args:
241
255
  # Show effective metadata (defaults + overrides)
@@ -483,7 +497,7 @@ class DashboardScreen(Screen):
483
497
  for btn_id, label in rows:
484
498
  messages.mount(Button(label, id=btn_id, classes="action-btn"))
485
499
  messages.scroll_end(animate=False)
486
- self.query_one("#chat-input", Input).focus()
500
+ self.query_one("#chat-input", ChatInput).focus()
487
501
 
488
502
  def _render_actions_list(self, command: str, api_client) -> None:
489
503
  """Render /actions list with clickable rows."""
@@ -646,10 +660,11 @@ class DashboardScreen(Screen):
646
660
  return
647
661
 
648
662
  if btn_id == "send-btn":
649
- chat_input = self.query_one("#chat-input", Input)
663
+ chat_input = self.query_one("#chat-input", ChatInput)
650
664
  message = chat_input.value.strip()
651
665
  if not message:
652
666
  return
667
+ chat_input.record(message)
653
668
  chat_input.value = ""
654
669
  if message.startswith("/"):
655
670
  self.handle_slash_command(message)
@@ -709,7 +724,7 @@ class DashboardScreen(Screen):
709
724
  else:
710
725
  return
711
726
 
712
- self.query_one("#chat-input", Input).focus()
727
+ self.query_one("#chat-input", ChatInput).focus()
713
728
 
714
729
  def add_message(self, text: str, msg_type: str = "assistant") -> None:
715
730
  """Add a message to the chat."""
@@ -733,7 +748,7 @@ class DashboardScreen(Screen):
733
748
  self._update_pending_files_bar()
734
749
  else:
735
750
  self.add_message(f"Upload failed: {message}", "error")
736
- self.query_one("#chat-input", Input).focus()
751
+ self.query_one("#chat-input", ChatInput).focus()
737
752
 
738
753
  def _update_pending_files_bar(self) -> None:
739
754
  """Update the pending-files bar to show queued file URLs or hide when empty."""
@@ -2,11 +2,103 @@
2
2
 
3
3
  from textual.app import ComposeResult
4
4
  from textual.containers import Container, Vertical, Horizontal
5
- from textual.widgets import Static, Button, Label, DataTable
5
+ from textual.widgets import Static, Button, Label, DataTable, Input
6
6
  from textual.reactive import reactive
7
+ from textual.suggester import SuggestFromList
7
8
  from rich.text import Text
8
9
 
9
10
 
11
+ # Slash commands for autocomplete
12
+ _SLASH_COMMANDS = [
13
+ "/help",
14
+ "/actions list",
15
+ "/actions get ",
16
+ "/runs list",
17
+ "/runs get ",
18
+ "/graphs list",
19
+ "/graphs get ",
20
+ "/graph-runs list",
21
+ "/graph-runs get ",
22
+ "/use ",
23
+ "/continue ",
24
+ "/new",
25
+ "/status",
26
+ "/cancel",
27
+ "/upload ",
28
+ "/files",
29
+ "/clear-files",
30
+ "/login",
31
+ "/logout",
32
+ "/metadata",
33
+ "/metadata set ",
34
+ "/metadata remove ",
35
+ "/metadata clear",
36
+ "/runtime status",
37
+ "/runtime start",
38
+ "/runtime stop",
39
+ "/runtime restart",
40
+ "/runtime list",
41
+ "/runtime logs",
42
+ ]
43
+
44
+
45
+ class ChatInput(Input):
46
+ """Input with command history (up/down arrows) and slash command autocomplete."""
47
+
48
+ _MAX_HISTORY = 200
49
+
50
+ def __init__(self, *args, **kwargs):
51
+ kwargs.setdefault("suggester", SuggestFromList(_SLASH_COMMANDS, case_sensitive=False))
52
+ super().__init__(*args, **kwargs)
53
+ self._history: list[str] = []
54
+ self._history_index: int = -1
55
+ self._draft: str = "" # saves current text when browsing history
56
+
57
+ def record(self, value: str) -> None:
58
+ """Add a submitted value to history."""
59
+ value = value.strip()
60
+ if not value:
61
+ return
62
+ # Deduplicate consecutive
63
+ if self._history and self._history[-1] == value:
64
+ return
65
+ self._history.append(value)
66
+ if len(self._history) > self._MAX_HISTORY:
67
+ self._history.pop(0)
68
+ self._history_index = -1
69
+ self._draft = ""
70
+
71
+ async def _on_key(self, event) -> None:
72
+ if event.key == "up":
73
+ if not self._history:
74
+ return
75
+ event.prevent_default()
76
+ event.stop()
77
+ if self._history_index == -1:
78
+ # Entering history — save current draft
79
+ self._draft = self.value
80
+ self._history_index = len(self._history) - 1
81
+ elif self._history_index > 0:
82
+ self._history_index -= 1
83
+ self.value = self._history[self._history_index]
84
+ self.cursor_position = len(self.value)
85
+ elif event.key == "down":
86
+ if self._history_index == -1:
87
+ return
88
+ event.prevent_default()
89
+ event.stop()
90
+ if self._history_index < len(self._history) - 1:
91
+ self._history_index += 1
92
+ self.value = self._history[self._history_index]
93
+ else:
94
+ # Past end — restore draft
95
+ self._history_index = -1
96
+ self.value = self._draft
97
+ self.cursor_position = len(self.value)
98
+ else:
99
+ await super()._on_key(event)
100
+
101
+
10
102
  class StatusBadge(Static):
11
103
  """A colored status badge widget."""
12
104