kiwi-code 0.0.22__tar.gz → 0.0.24__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.22 → kiwi_code-0.0.24}/CLAUDE.md +3 -3
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/PKG-INFO +4 -6
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/README.md +3 -5
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/pyproject.toml +1 -1
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_cli/cli.py +37 -7
- kiwi_code-0.0.24/src/kiwi_cli/server.py +49 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_runtime/main.py +1 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/main.py +16 -13
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/runtime_agent.py +2 -34
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/dashboard.py +78 -23
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/tests/conftest.py +3 -3
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/tests/test_reexec_kiwi.py +14 -3
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/tests/test_tui_headless.py +36 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/uv.lock +1 -1
- kiwi_code-0.0.22/src/kiwi_cli/config.py +0 -79
- kiwi_code-0.0.22/tests/test_config.py +0 -27
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/.gitignore +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/.python-version +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/Makefile +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_cli/client.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_cli/commands.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_runtime/snake_game/.gitignore +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/test_hello.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/tests/__init__.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.22 → kiwi_code-0.0.24}/tests/test_tokens.py +0 -0
|
@@ -39,7 +39,7 @@ There are three entry points defined in pyproject.toml:
|
|
|
39
39
|
┌─────────────────────────────────┐
|
|
40
40
|
│ Shared Libraries │
|
|
41
41
|
│ commands.py · client.py │
|
|
42
|
-
│ auth.py · models.py ·
|
|
42
|
+
│ auth.py · models.py · server.py│
|
|
43
43
|
└──────────┬──────────┬───────────┘
|
|
44
44
|
│ │
|
|
45
45
|
┌──────▼──────┐ └──────────────┐
|
|
@@ -60,11 +60,11 @@ There are three entry points defined in pyproject.toml:
|
|
|
60
60
|
- One runtime agent per working directory, managed via `runtime_manager.py`
|
|
61
61
|
- Runtime state stored in `~/.kiwi/runtimes/<key>/` (pid, log, cwd, tokens)
|
|
62
62
|
- Auth tokens stored in `~/.kiwi/tokens.json` with 0600 permissions
|
|
63
|
-
-
|
|
63
|
+
- No persisted app config; server is selected per invocation via `--server` (defaults to prod)
|
|
64
64
|
|
|
65
65
|
## Three Source Packages
|
|
66
66
|
|
|
67
|
-
- **`src/kiwi_cli/`** — Typer CLI app and shared infrastructure: auth, client,
|
|
67
|
+
- **`src/kiwi_cli/`** — Typer CLI app and shared infrastructure: auth, client, server, commands, models, runtime_manager, logger. Both `kiwi_tui` and the CLI entry point depend on this package.
|
|
68
68
|
- **`src/kiwi_tui/`** — Textual TUI app, screens, and widgets. Imports shared modules from `kiwi_cli`.
|
|
69
69
|
- **`src/kiwi_runtime/`** — Standalone WebSocket agent (~43KB `main.py`) that connects to Kiwi server and executes shell commands, sandboxed to CWD by default
|
|
70
70
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kiwi-code
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.24
|
|
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
|
|
@@ -48,7 +48,7 @@ pip install kiwi-code
|
|
|
48
48
|
|
|
49
49
|
Connect using a server preset:
|
|
50
50
|
|
|
51
|
-
kiwi
|
|
51
|
+
kiwi --server app
|
|
52
52
|
|
|
53
53
|
Available presets:
|
|
54
54
|
- app (prod)
|
|
@@ -58,10 +58,9 @@ Available presets:
|
|
|
58
58
|
|
|
59
59
|
The TUI will prompt for login if you’re not authenticated.
|
|
60
60
|
|
|
61
|
-
Tokens
|
|
61
|
+
Tokens are stored under:
|
|
62
62
|
|
|
63
63
|
- ~/.kiwi/tokens.json
|
|
64
|
-
- ~/.kiwi/config.json
|
|
65
64
|
|
|
66
65
|
### 4) (Optional) Start the Runtime
|
|
67
66
|
|
|
@@ -100,10 +99,9 @@ kiwi --server dev
|
|
|
100
99
|
|
|
101
100
|
The TUI will show a login screen if you’re not authenticated.
|
|
102
101
|
|
|
103
|
-
Tokens
|
|
102
|
+
Tokens are stored under:
|
|
104
103
|
|
|
105
104
|
- `~/.kiwi/tokens.json`
|
|
106
|
-
- `~/.kiwi/config.json`
|
|
107
105
|
|
|
108
106
|
---
|
|
109
107
|
|
|
@@ -19,7 +19,7 @@ pip install kiwi-code
|
|
|
19
19
|
|
|
20
20
|
Connect using a server preset:
|
|
21
21
|
|
|
22
|
-
kiwi
|
|
22
|
+
kiwi --server app
|
|
23
23
|
|
|
24
24
|
Available presets:
|
|
25
25
|
- app (prod)
|
|
@@ -29,10 +29,9 @@ Available presets:
|
|
|
29
29
|
|
|
30
30
|
The TUI will prompt for login if you’re not authenticated.
|
|
31
31
|
|
|
32
|
-
Tokens
|
|
32
|
+
Tokens are stored under:
|
|
33
33
|
|
|
34
34
|
- ~/.kiwi/tokens.json
|
|
35
|
-
- ~/.kiwi/config.json
|
|
36
35
|
|
|
37
36
|
### 4) (Optional) Start the Runtime
|
|
38
37
|
|
|
@@ -71,10 +70,9 @@ kiwi --server dev
|
|
|
71
70
|
|
|
72
71
|
The TUI will show a login screen if you’re not authenticated.
|
|
73
72
|
|
|
74
|
-
Tokens
|
|
73
|
+
Tokens are stored under:
|
|
75
74
|
|
|
76
75
|
- `~/.kiwi/tokens.json`
|
|
77
|
-
- `~/.kiwi/config.json`
|
|
78
76
|
|
|
79
77
|
---
|
|
80
78
|
|
|
@@ -8,8 +8,9 @@ logger.remove() # Suppress loguru console output in CLI mode
|
|
|
8
8
|
import typer
|
|
9
9
|
from autobots_client import AuthenticatedClient
|
|
10
10
|
|
|
11
|
-
from .config import ConfigManager
|
|
12
11
|
from .auth import TokenManager
|
|
12
|
+
from .models import AppConfig
|
|
13
|
+
from .server import http_url_from_server
|
|
13
14
|
from . import commands
|
|
14
15
|
|
|
15
16
|
app = typer.Typer(name="kiwi", help="Kiwi — interact with the Kiwi AI platform.")
|
|
@@ -26,13 +27,44 @@ app.add_typer(graph_runs_app)
|
|
|
26
27
|
|
|
27
28
|
# NOTE: kiwi-runtime is a standalone executable; this CLI does not manage it.
|
|
28
29
|
|
|
30
|
+
_SERVER: str | None = None
|
|
31
|
+
|
|
32
|
+
@app.callback()
|
|
33
|
+
def _main(
|
|
34
|
+
server: Optional[str] = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--server",
|
|
37
|
+
help=(
|
|
38
|
+
"Server to connect to. Use a preset name (app, dev, local) "
|
|
39
|
+
"or a full URL (e.g. https://custom.server.com)."
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
):
|
|
43
|
+
"""Global options for `kiwicli`."""
|
|
44
|
+
global _SERVER
|
|
45
|
+
_SERVER = server
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _backend_url() -> str:
|
|
49
|
+
"""Resolve the HTTP backend URL for this invocation (runtime-only)."""
|
|
50
|
+
if _SERVER:
|
|
51
|
+
try:
|
|
52
|
+
return http_url_from_server(_SERVER)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
return AppConfig().backend_url
|
|
56
|
+
|
|
57
|
+
|
|
29
58
|
def _get_client() -> AuthenticatedClient:
|
|
30
|
-
config = ConfigManager().config
|
|
31
59
|
tm = TokenManager()
|
|
32
60
|
if not tm.is_authenticated():
|
|
33
61
|
typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
|
|
34
62
|
raise typer.Exit(code=1)
|
|
35
|
-
return AuthenticatedClient(
|
|
63
|
+
return AuthenticatedClient(
|
|
64
|
+
base_url=_backend_url(),
|
|
65
|
+
token=tm.get_access_token(),
|
|
66
|
+
raise_on_unexpected_status=False,
|
|
67
|
+
)
|
|
36
68
|
|
|
37
69
|
|
|
38
70
|
def _print(lines: list[str]) -> None:
|
|
@@ -127,9 +159,8 @@ def login(
|
|
|
127
159
|
from .client import AutobotsClientWrapper
|
|
128
160
|
from .models import LoginCredentials
|
|
129
161
|
|
|
130
|
-
config = ConfigManager().config
|
|
131
162
|
token_manager = TokenManager()
|
|
132
|
-
wrapper = AutobotsClientWrapper(base_url=
|
|
163
|
+
wrapper = AutobotsClientWrapper(base_url=_backend_url())
|
|
133
164
|
success, tokens, message = wrapper.login(LoginCredentials(username=username, password=password))
|
|
134
165
|
|
|
135
166
|
if success and tokens:
|
|
@@ -150,9 +181,8 @@ def logout():
|
|
|
150
181
|
def whoami():
|
|
151
182
|
"""Show current authentication status."""
|
|
152
183
|
tm = TokenManager()
|
|
153
|
-
config = ConfigManager().config
|
|
154
184
|
if tm.is_authenticated():
|
|
155
|
-
typer.echo(f"Authenticated\nServer: {
|
|
185
|
+
typer.echo(f"Authenticated\nServer: {_backend_url()}")
|
|
156
186
|
else:
|
|
157
187
|
typer.echo("Not authenticated. Run `kiwi login` to sign in.")
|
|
158
188
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Server / environment resolution helpers.
|
|
2
|
+
|
|
3
|
+
Kiwi Code accepts a ``--server`` argument in both the TUI (``kiwi``) and
|
|
4
|
+
non-interactive CLI (``kiwicli``).
|
|
5
|
+
|
|
6
|
+
The value can be:
|
|
7
|
+
- a preset name: ``app`` | ``dev`` | ``local``
|
|
8
|
+
- a full URL (http/https/ws/wss)
|
|
9
|
+
- a bare hostname (assumed https)
|
|
10
|
+
|
|
11
|
+
This module intentionally contains *no persistence*. It is runtime-only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SERVER_PRESETS: dict[str, dict[str, str]] = {
|
|
18
|
+
"app": {"ws": "wss://api.meetkiwi.ai", "http": "https://api.meetkiwi.ai"},
|
|
19
|
+
"dev": {"ws": "wss://dev.api.myautobots.com", "http": "https://dev.api.myautobots.com"},
|
|
20
|
+
"local": {"ws": "ws://localhost:8000", "http": "http://localhost:8000"},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def server_from_backend_url(backend_url: str) -> str:
|
|
25
|
+
"""Pick a runtime ``--server`` value from an HTTP backend_url."""
|
|
26
|
+
url = backend_url.rstrip("/")
|
|
27
|
+
for preset_name, preset in SERVER_PRESETS.items():
|
|
28
|
+
if preset.get("http") == url:
|
|
29
|
+
return preset_name
|
|
30
|
+
return url
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def http_url_from_server(server: str) -> str:
|
|
34
|
+
"""Resolve runtime ``--server`` string to the HTTP base URL."""
|
|
35
|
+
preset = SERVER_PRESETS.get(server)
|
|
36
|
+
if preset:
|
|
37
|
+
return preset["http"]
|
|
38
|
+
|
|
39
|
+
url = server.rstrip("/")
|
|
40
|
+
if url.startswith("https://"):
|
|
41
|
+
return url
|
|
42
|
+
if url.startswith("http://"):
|
|
43
|
+
return url
|
|
44
|
+
if url.startswith("wss://"):
|
|
45
|
+
return url.replace("wss://", "https://", 1)
|
|
46
|
+
if url.startswith("ws://"):
|
|
47
|
+
return url.replace("ws://", "http://", 1)
|
|
48
|
+
# bare host
|
|
49
|
+
return f"https://{url}"
|
|
@@ -11,7 +11,7 @@ from textual.binding import Binding
|
|
|
11
11
|
from loguru import logger
|
|
12
12
|
|
|
13
13
|
from kiwi_cli.logger import setup_logging
|
|
14
|
-
from kiwi_cli.
|
|
14
|
+
from kiwi_cli.models import AppConfig
|
|
15
15
|
from kiwi_cli.client import AutobotsClientWrapper
|
|
16
16
|
from kiwi_cli.auth import TokenManager
|
|
17
17
|
# from kiwi_cli import runtime_manager
|
|
@@ -148,19 +148,18 @@ class AutobotsTUI(App):
|
|
|
148
148
|
|
|
149
149
|
def __init__(
|
|
150
150
|
self,
|
|
151
|
-
|
|
151
|
+
config: AppConfig | None = None,
|
|
152
152
|
token_manager: TokenManager | None = None,
|
|
153
153
|
runtime_args: RuntimeConnectArgs | None = None,
|
|
154
154
|
):
|
|
155
155
|
"""Initialize Autobots TUI app.
|
|
156
156
|
|
|
157
157
|
Args:
|
|
158
|
-
|
|
158
|
+
config: Runtime configuration (no persistence)
|
|
159
159
|
token_manager: Token manager instance
|
|
160
160
|
"""
|
|
161
161
|
super().__init__()
|
|
162
|
-
self.
|
|
163
|
-
self.config = self.config_manager.config
|
|
162
|
+
self.config: AppConfig = config or AppConfig()
|
|
164
163
|
|
|
165
164
|
# Runtime flags for this TUI session (mirrors `kiwi-runtime connect ...`).
|
|
166
165
|
# Note: runtime processes themselves are tracked per *run_id* (see runtime_agent.py).
|
|
@@ -652,7 +651,8 @@ class AutobotsTUI(App):
|
|
|
652
651
|
"""Toggle dark mode."""
|
|
653
652
|
self.dark = not self.dark
|
|
654
653
|
theme = "dark" if self.dark else "light"
|
|
655
|
-
|
|
654
|
+
# Runtime-only: do not persist theme to disk.
|
|
655
|
+
self.config.theme = theme
|
|
656
656
|
logger.info(f"Theme changed to {theme}")
|
|
657
657
|
self.notify(f"Theme: {theme}", severity="information")
|
|
658
658
|
|
|
@@ -753,24 +753,27 @@ class AutobotsTUI(App):
|
|
|
753
753
|
|
|
754
754
|
|
|
755
755
|
def _run_tui(runtime_args: RuntimeConnectArgs | None = None):
|
|
756
|
-
"""Run the TUI in the terminal.
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
756
|
+
"""Run the TUI in the terminal.
|
|
757
|
+
|
|
758
|
+
Note: Kiwi Code no longer persists app configuration (e.g. backend server).
|
|
759
|
+
All configuration is resolved at runtime from CLI args + defaults.
|
|
760
|
+
"""
|
|
761
|
+
config = AppConfig()
|
|
760
762
|
|
|
761
|
-
# If the user passed `--server`, use the same value for the TUI HTTP backend
|
|
762
|
-
# (do not persist to disk; this is a runtime override).
|
|
763
|
+
# If the user passed `--server`, use the same value for the TUI HTTP backend.
|
|
763
764
|
if runtime_args and runtime_args.server:
|
|
764
765
|
try:
|
|
765
766
|
config.backend_url = http_url_from_server(runtime_args.server)
|
|
766
767
|
except Exception:
|
|
767
768
|
pass
|
|
768
769
|
|
|
770
|
+
setup_logging(log_level=config.log_level)
|
|
771
|
+
|
|
769
772
|
logger.info("=" * 60)
|
|
770
773
|
logger.info("Starting Autobots TUI")
|
|
771
774
|
logger.info("=" * 60)
|
|
772
775
|
|
|
773
|
-
app = AutobotsTUI(
|
|
776
|
+
app = AutobotsTUI(config=config, runtime_args=runtime_args)
|
|
774
777
|
app.run()
|
|
775
778
|
|
|
776
779
|
logger.info("Autobots TUI terminated")
|
|
@@ -33,14 +33,9 @@ import uuid
|
|
|
33
33
|
import psutil
|
|
34
34
|
from loguru import logger
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
from kiwi_cli.server import SERVER_PRESETS, http_url_from_server, server_from_backend_url
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
"app": {"ws": "wss://api.meetkiwi.ai", "http": "https://api.meetkiwi.ai"},
|
|
41
|
-
"dev": {"ws": "wss://dev.api.myautobots.com", "http": "https://dev.api.myautobots.com"},
|
|
42
|
-
"local": {"ws": "ws://localhost:8000", "http": "http://localhost:8000"},
|
|
43
|
-
}
|
|
38
|
+
RuntimeScope = Literal["restricted", "full"]
|
|
44
39
|
|
|
45
40
|
|
|
46
41
|
@dataclass(frozen=True)
|
|
@@ -62,33 +57,6 @@ BY_RUN_DIR = RUNTIMES_DIR / "by-run"
|
|
|
62
57
|
PENDING_DIR = RUNTIMES_DIR / "pending"
|
|
63
58
|
|
|
64
59
|
|
|
65
|
-
def server_from_backend_url(backend_url: str) -> str:
|
|
66
|
-
"""Pick a runtime ``--server`` value from an HTTP backend_url."""
|
|
67
|
-
url = backend_url.rstrip("/")
|
|
68
|
-
for preset_name, preset in SERVER_PRESETS.items():
|
|
69
|
-
if preset.get("http") == url:
|
|
70
|
-
return preset_name
|
|
71
|
-
return url
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def http_url_from_server(server: str) -> str:
|
|
75
|
-
"""Resolve runtime ``--server`` string to the HTTP base URL."""
|
|
76
|
-
preset = SERVER_PRESETS.get(server)
|
|
77
|
-
if preset:
|
|
78
|
-
return preset["http"]
|
|
79
|
-
|
|
80
|
-
url = server.rstrip("/")
|
|
81
|
-
if url.startswith("https://"):
|
|
82
|
-
return url
|
|
83
|
-
if url.startswith("http://"):
|
|
84
|
-
return url
|
|
85
|
-
if url.startswith("wss://"):
|
|
86
|
-
return url.replace("wss://", "https://", 1)
|
|
87
|
-
if url.startswith("ws://"):
|
|
88
|
-
return url.replace("ws://", "http://", 1)
|
|
89
|
-
# bare host
|
|
90
|
-
return f"https://{url}"
|
|
91
|
-
|
|
92
60
|
|
|
93
61
|
def _read_pid(path: Path) -> int | None:
|
|
94
62
|
if not path.exists():
|
|
@@ -6,7 +6,7 @@ from textual.app import ComposeResult
|
|
|
6
6
|
from textual.binding import Binding
|
|
7
7
|
from textual.screen import Screen
|
|
8
8
|
from textual.widgets import Header, Footer, Input, Static, Button, Markdown, LoadingIndicator
|
|
9
|
-
from textual.containers import Vertical, VerticalScroll, Horizontal
|
|
9
|
+
from textual.containers import Vertical, VerticalScroll, Horizontal, Center
|
|
10
10
|
from kiwi_tui.screens.file_browser import FileBrowserScreen
|
|
11
11
|
from kiwi_tui.screens.attach_content import AttachContentScreen
|
|
12
12
|
from kiwi_tui.screens.slash_picker import SlashPickerScreen
|
|
@@ -38,6 +38,28 @@ class UserMessageRow(Horizontal):
|
|
|
38
38
|
yield Static(self._text, classes="user-body", markup=False)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
|
|
42
|
+
class AssistantMessageRow(Horizontal):
|
|
43
|
+
"""A single assistant message rendered with a left dot + markdown body."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, markdown_text: str, *, classes: str = "") -> None:
|
|
46
|
+
super().__init__(classes=classes)
|
|
47
|
+
self._markdown_text = markdown_text
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
# Solid dot marker on the left side of the model response.
|
|
51
|
+
yield Static("●", classes="assistant-dot", markup=False)
|
|
52
|
+
yield Markdown(self._markdown_text, classes="assistant-body")
|
|
53
|
+
|
|
54
|
+
def update_markdown(self, markdown_text: str) -> None:
|
|
55
|
+
"""Update the markdown content in-place."""
|
|
56
|
+
self._markdown_text = markdown_text
|
|
57
|
+
try:
|
|
58
|
+
self.query_one(".assistant-body", Markdown).update(markdown_text)
|
|
59
|
+
except Exception:
|
|
60
|
+
# Defensive: if the widget tree isn't ready or the child was removed.
|
|
61
|
+
pass
|
|
62
|
+
|
|
41
63
|
# Footer hint for inserting a newline differs by OS:
|
|
42
64
|
# - macOS terminals often don't forward modified Enter combos to terminal apps reliably, so we advertise Ctrl+N.
|
|
43
65
|
# - Windows terminals tend to support Shift+Enter for multi-line input.
|
|
@@ -95,7 +117,7 @@ class DashboardScreen(Screen):
|
|
|
95
117
|
|
|
96
118
|
#run-status-bar {
|
|
97
119
|
width: 100%;
|
|
98
|
-
height:
|
|
120
|
+
height: 5;
|
|
99
121
|
background: $background;
|
|
100
122
|
}
|
|
101
123
|
|
|
@@ -129,12 +151,14 @@ class DashboardScreen(Screen):
|
|
|
129
151
|
}
|
|
130
152
|
#copy-run-id {
|
|
131
153
|
width: auto;
|
|
132
|
-
height
|
|
154
|
+
/* Explicit height so the full border (top+bottom) is visible. */
|
|
155
|
+
height: 3;
|
|
133
156
|
padding: 0 1;
|
|
134
|
-
margin: 0
|
|
157
|
+
margin: 0;
|
|
135
158
|
}
|
|
136
159
|
|
|
137
160
|
|
|
161
|
+
|
|
138
162
|
#activity-bar {
|
|
139
163
|
width: 100%;
|
|
140
164
|
height: 1;
|
|
@@ -168,7 +192,8 @@ class DashboardScreen(Screen):
|
|
|
168
192
|
width: 100%;
|
|
169
193
|
height: auto;
|
|
170
194
|
padding: 0 1;
|
|
171
|
-
|
|
195
|
+
/* Add breathing room between all messages (user/assistant/info/error/etc.). */
|
|
196
|
+
margin: 1 0 0 0;
|
|
172
197
|
}
|
|
173
198
|
|
|
174
199
|
.user-message {
|
|
@@ -190,13 +215,25 @@ class DashboardScreen(Screen):
|
|
|
190
215
|
|
|
191
216
|
.assistant-message {
|
|
192
217
|
color: $text;
|
|
193
|
-
margin: 0;
|
|
194
218
|
padding: 0 1;
|
|
195
219
|
}
|
|
196
220
|
|
|
221
|
+
.assistant-dot {
|
|
222
|
+
width: 2;
|
|
223
|
+
padding: 0 1 0 0;
|
|
224
|
+
color: $brand-cyan;
|
|
225
|
+
text-style: bold;
|
|
226
|
+
content-align: center top;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.assistant-body {
|
|
230
|
+
width: 1fr;
|
|
231
|
+
}
|
|
232
|
+
|
|
197
233
|
.assistant-message Markdown {
|
|
198
234
|
margin: 0;
|
|
199
235
|
padding: 0;
|
|
236
|
+
width: 1fr;
|
|
200
237
|
}
|
|
201
238
|
|
|
202
239
|
.error-message {
|
|
@@ -372,8 +409,10 @@ class DashboardScreen(Screen):
|
|
|
372
409
|
with Vertical(id="status-action-col"):
|
|
373
410
|
yield Static("", id="status-action", markup=False)
|
|
374
411
|
with Vertical(id="status-run-col"):
|
|
375
|
-
|
|
376
|
-
|
|
412
|
+
with Center():
|
|
413
|
+
yield Static("", id="status-run", markup=False)
|
|
414
|
+
with Center():
|
|
415
|
+
yield Button("Copy", id="copy-run-id", variant="default")
|
|
377
416
|
|
|
378
417
|
with Vertical(id="input-bar"):
|
|
379
418
|
with Horizontal(id="activity-bar"):
|
|
@@ -1400,14 +1439,16 @@ class DashboardScreen(Screen):
|
|
|
1400
1439
|
return
|
|
1401
1440
|
chat_input = self.query_one("#chat-input", ChatInput)
|
|
1402
1441
|
message = chat_input.value.strip()
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1442
|
+
# Allow sending empty prompts (useful for "continue"-style turns).
|
|
1443
|
+
# We still clear the input, but we don't record empty messages in history.
|
|
1444
|
+
if message:
|
|
1445
|
+
chat_input.record(message)
|
|
1406
1446
|
chat_input.value = ""
|
|
1407
1447
|
if message.startswith("/"):
|
|
1408
1448
|
self.handle_slash_command(message)
|
|
1409
1449
|
else:
|
|
1410
|
-
|
|
1450
|
+
# Render a single space so the UI still shows a user row for empty prompts.
|
|
1451
|
+
self.add_message(message if message else " ", "user")
|
|
1411
1452
|
self.process_message(message)
|
|
1412
1453
|
|
|
1413
1454
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
@@ -1518,8 +1559,8 @@ class DashboardScreen(Screen):
|
|
|
1518
1559
|
messages = self.query_one("#messages", VerticalScroll)
|
|
1519
1560
|
css_class = f"message {msg_type}-message"
|
|
1520
1561
|
if msg_type == "assistant":
|
|
1521
|
-
|
|
1522
|
-
messages.mount(
|
|
1562
|
+
prepared = self._prepare_markdown(text)
|
|
1563
|
+
messages.mount(AssistantMessageRow(prepared, classes=css_class))
|
|
1523
1564
|
elif msg_type == "user":
|
|
1524
1565
|
# Render a styled "YOU" label without enabling markup (keeps input safe).
|
|
1525
1566
|
messages.mount(UserMessageRow(str(text), classes=css_class))
|
|
@@ -1940,7 +1981,11 @@ class DashboardScreen(Screen):
|
|
|
1940
1981
|
|
|
1941
1982
|
self._set_streaming(False)
|
|
1942
1983
|
|
|
1943
|
-
def update_streaming_message(
|
|
1984
|
+
def update_streaming_message(
|
|
1985
|
+
self,
|
|
1986
|
+
output: any,
|
|
1987
|
+
widget_ref: Markdown | AssistantMessageRow | None = None,
|
|
1988
|
+
) -> Markdown | AssistantMessageRow | None:
|
|
1944
1989
|
"""Update or create a streaming message widget with new output.
|
|
1945
1990
|
|
|
1946
1991
|
Returns the widget being updated/created for future updates.
|
|
@@ -1951,21 +1996,24 @@ class DashboardScreen(Screen):
|
|
|
1951
1996
|
|
|
1952
1997
|
messages = self.query_one("#messages", VerticalScroll)
|
|
1953
1998
|
|
|
1954
|
-
|
|
1999
|
+
markdown_text = self._prepare_markdown(text_content)
|
|
1955
2000
|
if widget_ref is None:
|
|
1956
|
-
widget_ref =
|
|
2001
|
+
widget_ref = AssistantMessageRow(markdown_text, classes="message assistant-message")
|
|
1957
2002
|
messages.mount(widget_ref)
|
|
1958
2003
|
else:
|
|
1959
2004
|
try:
|
|
1960
|
-
widget_ref
|
|
2005
|
+
if isinstance(widget_ref, AssistantMessageRow):
|
|
2006
|
+
widget_ref.update_markdown(markdown_text)
|
|
2007
|
+
else:
|
|
2008
|
+
# Backward-compat: if an older ref is a Markdown widget.
|
|
2009
|
+
widget_ref.update(markdown_text)
|
|
1961
2010
|
except Exception as e:
|
|
1962
2011
|
logger.warning(f"Failed to update widget: {e}")
|
|
1963
|
-
widget_ref =
|
|
2012
|
+
widget_ref = AssistantMessageRow(markdown_text, classes="message assistant-message")
|
|
1964
2013
|
messages.mount(widget_ref)
|
|
1965
2014
|
|
|
1966
2015
|
messages.scroll_end(animate=False)
|
|
1967
2016
|
return widget_ref
|
|
1968
|
-
|
|
1969
2017
|
def extract_text_from_output(self, output: any) -> str:
|
|
1970
2018
|
"""Extract text content from output blocks structure and clean it for display."""
|
|
1971
2019
|
if not isinstance(output, dict):
|
|
@@ -2155,10 +2203,17 @@ class DashboardScreen(Screen):
|
|
|
2155
2203
|
messages = self.query_one("#messages", VerticalScroll)
|
|
2156
2204
|
assistant_messages = messages.query(".assistant-message")
|
|
2157
2205
|
|
|
2206
|
+
prepared = self._prepare_markdown(text)
|
|
2207
|
+
|
|
2158
2208
|
if assistant_messages:
|
|
2159
|
-
# Update the last assistant message
|
|
2160
2209
|
last_msg = assistant_messages[-1]
|
|
2161
|
-
last_msg
|
|
2210
|
+
if isinstance(last_msg, AssistantMessageRow):
|
|
2211
|
+
last_msg.update_markdown(prepared)
|
|
2212
|
+
else:
|
|
2213
|
+
# Backward-compat if we have an older Markdown widget in the tree.
|
|
2214
|
+
try:
|
|
2215
|
+
last_msg.update(prepared)
|
|
2216
|
+
except Exception:
|
|
2217
|
+
self.add_message(text, "assistant")
|
|
2162
2218
|
else:
|
|
2163
|
-
# No existing message, create new one
|
|
2164
2219
|
self.add_message(text, "assistant")
|
|
@@ -16,9 +16,9 @@ import pytest
|
|
|
16
16
|
def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
17
17
|
"""Redirect ``$HOME`` (and ``USERPROFILE`` on Windows) to a tmp dir.
|
|
18
18
|
|
|
19
|
-
Kiwi's
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
Kiwi's token storage and runtime-state helpers default their paths to
|
|
20
|
+
``Path.home() / ".kiwi"``. Pointing HOME at a tmp dir sandboxes tests
|
|
21
|
+
so they don't pollute the developer's real home.
|
|
22
22
|
"""
|
|
23
23
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
24
24
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
@@ -64,6 +64,17 @@ def test_reexec_symlink_sets_comm_to_kiwi(isolated_home: Path) -> None:
|
|
|
64
64
|
timeout=30,
|
|
65
65
|
)
|
|
66
66
|
assert result.returncode == 0, result.stderr
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
|
|
68
|
+
comm = result.stdout.strip()
|
|
69
|
+
if sys.platform == "darwin":
|
|
70
|
+
# On macOS (especially framework-based Python builds), the kernel-reported
|
|
71
|
+
# process accounting name often resolves to the underlying interpreter
|
|
72
|
+
# (e.g. "Python") rather than the symlink basename. The re-exec trick is
|
|
73
|
+
# still safe to run, but the comm value is not reliable across distros.
|
|
74
|
+
assert comm in {"kiwi", "Python"}, (
|
|
75
|
+
f"expected comm in {{'kiwi','Python'}}, got {result.stdout!r}\nstderr={result.stderr!r}"
|
|
76
|
+
)
|
|
77
|
+
else:
|
|
78
|
+
assert comm == "kiwi", (
|
|
79
|
+
f"expected comm=kiwi, got {result.stdout!r}\nstderr={result.stderr!r}"
|
|
80
|
+
)
|
|
@@ -78,6 +78,42 @@ async def test_tui_action_switch_clears_chat(isolated_home: Path, monkeypatch: p
|
|
|
78
78
|
assert len(list(messages.children)) == 0
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
|
|
82
|
+
@pytest.mark.asyncio
|
|
83
|
+
async def test_tui_allows_empty_prompt(isolated_home: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
84
|
+
"""Sending an empty prompt should still trigger a run."""
|
|
85
|
+
# Create tokens so the app boots straight into the Dashboard screen.
|
|
86
|
+
tokens_path = isolated_home / ".kiwi" / "tokens.json"
|
|
87
|
+
tokens_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
tokens_path.write_text(
|
|
89
|
+
json.dumps(
|
|
90
|
+
{
|
|
91
|
+
"access_token": "test-access-token",
|
|
92
|
+
"refresh_token": "test-refresh-token",
|
|
93
|
+
"token_type": "Bearer",
|
|
94
|
+
"expires_at": None,
|
|
95
|
+
}
|
|
96
|
+
),
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
from kiwi_tui.screens.dashboard import DashboardScreen
|
|
101
|
+
called: list[str] = []
|
|
102
|
+
monkeypatch.setattr(DashboardScreen, "process_message", lambda self, msg: called.append(msg))
|
|
103
|
+
|
|
104
|
+
from kiwi_tui.main import AutobotsTUI
|
|
105
|
+
app = AutobotsTUI()
|
|
106
|
+
async with app.run_test() as pilot:
|
|
107
|
+
await pilot.pause()
|
|
108
|
+
assert type(app.screen).__name__ == "DashboardScreen"
|
|
109
|
+
screen = app.screen
|
|
110
|
+
chat_input = screen.query_one("#chat-input")
|
|
111
|
+
chat_input.value = ""
|
|
112
|
+
screen._do_send()
|
|
113
|
+
await pilot.pause()
|
|
114
|
+
|
|
115
|
+
assert called == [""]
|
|
116
|
+
|
|
81
117
|
@pytest.mark.asyncio
|
|
82
118
|
async def test_tui_quits_cleanly(isolated_home: Path) -> None:
|
|
83
119
|
from kiwi_tui.main import AutobotsTUI
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
"""Configuration management for Autobots TUI."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
from typing import Optional
|
|
6
|
-
from loguru import logger
|
|
7
|
-
from .models import AppConfig
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ConfigManager:
|
|
11
|
-
"""Manages application configuration."""
|
|
12
|
-
|
|
13
|
-
def __init__(self, config_path: Optional[Path] = None):
|
|
14
|
-
"""Initialize config manager.
|
|
15
|
-
|
|
16
|
-
Args:
|
|
17
|
-
config_path: Path to config file, defaults to ~/.kiwi/config.json
|
|
18
|
-
"""
|
|
19
|
-
if config_path is None:
|
|
20
|
-
config_path = Path.home() / ".kiwi" / "config.json"
|
|
21
|
-
|
|
22
|
-
self.config_path = config_path
|
|
23
|
-
self._config: Optional[AppConfig] = None
|
|
24
|
-
|
|
25
|
-
def load(self) -> AppConfig:
|
|
26
|
-
"""Load configuration from file or create default."""
|
|
27
|
-
if self.config_path.exists():
|
|
28
|
-
try:
|
|
29
|
-
with open(self.config_path, "r") as f:
|
|
30
|
-
data = json.load(f)
|
|
31
|
-
self._config = AppConfig(**data)
|
|
32
|
-
logger.info(f"Configuration loaded from {self.config_path}")
|
|
33
|
-
except Exception as e:
|
|
34
|
-
logger.error(f"Failed to load config: {e}, using defaults")
|
|
35
|
-
self._config = AppConfig()
|
|
36
|
-
else:
|
|
37
|
-
logger.info("No config file found, using defaults")
|
|
38
|
-
self._config = AppConfig()
|
|
39
|
-
self.save()
|
|
40
|
-
|
|
41
|
-
return self._config
|
|
42
|
-
|
|
43
|
-
def save(self) -> None:
|
|
44
|
-
"""Save configuration to file."""
|
|
45
|
-
if self._config is None:
|
|
46
|
-
logger.warning("No config to save")
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
with open(self.config_path, "w") as f:
|
|
53
|
-
json.dump(self._config.model_dump(), f, indent=2, default=str)
|
|
54
|
-
logger.info(f"Configuration saved to {self.config_path}")
|
|
55
|
-
except Exception as e:
|
|
56
|
-
logger.error(f"Failed to save config: {e}")
|
|
57
|
-
|
|
58
|
-
@property
|
|
59
|
-
def config(self) -> AppConfig:
|
|
60
|
-
"""Get current configuration."""
|
|
61
|
-
if self._config is None:
|
|
62
|
-
return self.load()
|
|
63
|
-
return self._config
|
|
64
|
-
|
|
65
|
-
def update(self, **kwargs) -> None:
|
|
66
|
-
"""Update configuration values.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
**kwargs: Configuration fields to update
|
|
70
|
-
"""
|
|
71
|
-
if self._config is None:
|
|
72
|
-
self.load()
|
|
73
|
-
|
|
74
|
-
for key, value in kwargs.items():
|
|
75
|
-
if hasattr(self._config, key):
|
|
76
|
-
setattr(self._config, key, value)
|
|
77
|
-
logger.debug(f"Config updated: {key} = {value}")
|
|
78
|
-
|
|
79
|
-
self.save()
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"""ConfigManager round-trip tests."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def test_config_creates_default(isolated_home: Path) -> None:
|
|
8
|
-
from kiwi_cli.config import ConfigManager
|
|
9
|
-
|
|
10
|
-
mgr = ConfigManager()
|
|
11
|
-
cfg = mgr.load()
|
|
12
|
-
# Default config must at minimum expose a backend URL.
|
|
13
|
-
assert cfg.backend_url
|
|
14
|
-
assert cfg.backend_url.startswith(("http://", "https://"))
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def test_config_update_persists(isolated_home: Path) -> None:
|
|
18
|
-
from kiwi_cli.config import ConfigManager
|
|
19
|
-
|
|
20
|
-
mgr = ConfigManager()
|
|
21
|
-
mgr.load()
|
|
22
|
-
mgr.update(theme="light")
|
|
23
|
-
|
|
24
|
-
# Re-read with a fresh manager to prove the change hit disk.
|
|
25
|
-
mgr2 = ConfigManager()
|
|
26
|
-
cfg2 = mgr2.load()
|
|
27
|
-
assert cfg2.theme == "light"
|
|
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
|