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.
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/PKG-INFO +2 -1
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/pyproject.toml +2 -1
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/cli.py +2 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_runtime/main.py +264 -6
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/main.py +4 -3
- kiwi_code-0.0.8/src/kiwi_tui/screens/__init__.py +7 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/screens/dashboard.py +22 -7
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/widgets.py +93 -1
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/uv.lock +618 -567
- kiwi_code-0.0.6/src/kiwi_tui/screens/__init__.py +0 -9
- kiwi_code-0.0.6/src/kiwi_tui/screens/actions.py +0 -271
- kiwi_code-0.0.6/src/kiwi_tui/screens/autobots.py +0 -216
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/.gitignore +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/.python-version +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/CLAUDE.md +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/Makefile +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/README.md +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/config.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.6 → kiwi_code-0.0.8}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {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.
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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,
|
|
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")
|
|
@@ -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
|
|
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",
|
|
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
|
-
#
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|