delos-cli 0.1.0__py3-none-any.whl

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 (43) hide show
  1. delos_cli/__init__.py +3 -0
  2. delos_cli/agent/__init__.py +34 -0
  3. delos_cli/agent/session.py +111 -0
  4. delos_cli/agent/tools.py +131 -0
  5. delos_cli/agent/transport.py +102 -0
  6. delos_cli/apps/__init__.py +6 -0
  7. delos_cli/apps/base.py +101 -0
  8. delos_cli/apps/chat/__init__.py +5 -0
  9. delos_cli/apps/chat/app.py +149 -0
  10. delos_cli/apps/chat/commands.py +17 -0
  11. delos_cli/apps/chat/render.py +188 -0
  12. delos_cli/apps/chat/replay.py +108 -0
  13. delos_cli/auth/__init__.py +24 -0
  14. delos_cli/auth/config.py +282 -0
  15. delos_cli/auth/mfa.py +120 -0
  16. delos_cli/auth/oauth.py +336 -0
  17. delos_cli/auth/token_manager.py +136 -0
  18. delos_cli/commands/__init__.py +10 -0
  19. delos_cli/commands/base.py +54 -0
  20. delos_cli/commands/builtin.py +160 -0
  21. delos_cli/ctx.py +65 -0
  22. delos_cli/loop.py +19 -0
  23. delos_cli/main.py +230 -0
  24. delos_cli/state.py +28 -0
  25. delos_cli/tools/__init__.py +20 -0
  26. delos_cli/tools/edit_content.py +193 -0
  27. delos_cli/tools/run_shell.py +150 -0
  28. delos_cli/tools/write_content.py +120 -0
  29. delos_cli/transport/__init__.py +24 -0
  30. delos_cli/transport/chats.py +235 -0
  31. delos_cli/transport/client.py +321 -0
  32. delos_cli/transport/models.py +19 -0
  33. delos_cli/ui/__init__.py +6 -0
  34. delos_cli/ui/chat_picker.py +151 -0
  35. delos_cli/ui/completer.py +68 -0
  36. delos_cli/ui/lexer.py +62 -0
  37. delos_cli/ui/output.py +180 -0
  38. delos_cli/ui/repl.py +679 -0
  39. delos_cli/ui/style.py +24 -0
  40. delos_cli-0.1.0.dist-info/METADATA +104 -0
  41. delos_cli-0.1.0.dist-info/RECORD +43 -0
  42. delos_cli-0.1.0.dist-info/WHEEL +4 -0
  43. delos_cli-0.1.0.dist-info/entry_points.txt +2 -0
delos_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Delos CLI — terminal REPL for the Cosmos chat agent."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,34 @@
1
+ """High-level wrapper around the backend's ``/{app}/agent/*`` routes.
2
+
3
+ :class:`AgentSession` drives one user turn end-to-end: streams completions,
4
+ detects unresolved tool calls (= client-side tools), resolves them via
5
+ registered handlers, and re-issues continuation requests until the agent
6
+ run is fully complete.
7
+
8
+ A :class:`ToolRegistry` is the single extension point — apps register:
9
+
10
+ * **Renderers** — how a server-side tool's events show up in the terminal.
11
+ * **Handlers** — how a client-side tool collects input from the user.
12
+
13
+ Both have sensible defaults so apps work out of the box.
14
+ """
15
+
16
+ from .session import AgentSession
17
+ from .tools import (
18
+ ToolHandler,
19
+ ToolRegistry,
20
+ ToolRenderer,
21
+ default_handler,
22
+ default_renderer,
23
+ )
24
+ from .transport import AgentTransport
25
+
26
+ __all__ = [
27
+ "AgentSession",
28
+ "AgentTransport",
29
+ "ToolHandler",
30
+ "ToolRegistry",
31
+ "ToolRenderer",
32
+ "default_handler",
33
+ "default_renderer",
34
+ ]
@@ -0,0 +1,111 @@
1
+ """AgentSession — drives one user turn end-to-end.
2
+
3
+ A turn may span multiple SSE streams: each client-side tool call ends
4
+ one stream and starts another with ``tool_continuation=true``. From the
5
+ caller's POV, :meth:`AgentSession.send_turn` is a single async iterator
6
+ of UI events — all the multi-stream stitching is internal.
7
+
8
+ A :class:`ToolRegistry` resolves both how server-tool events render and
9
+ how client-tool calls collect input from the user.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import AsyncIterator
18
+
19
+ from delos_cli.ctx import Ctx
20
+
21
+ from .tools import ToolRegistry
22
+ from .transport import AgentTransport
23
+
24
+
25
+ class AgentSession:
26
+ """One session per ``(transport, chat_id)`` pair."""
27
+
28
+ def __init__(
29
+ self,
30
+ transport: AgentTransport,
31
+ chat_id: str,
32
+ registry: ToolRegistry,
33
+ ) -> None:
34
+ """Bind to a chat row. Multiple turns share the same session."""
35
+ self._transport = transport
36
+ self._chat_id = chat_id
37
+ self._registry = registry
38
+
39
+ @property
40
+ def chat_id(self) -> str:
41
+ """The chat row id this session is bound to."""
42
+ return self._chat_id
43
+
44
+ async def send_turn(
45
+ self,
46
+ body: dict[str, Any],
47
+ ctx: Ctx,
48
+ ) -> AsyncIterator[dict[str, Any]]:
49
+ """Send one user turn and yield every UI event the agent emits.
50
+
51
+ Streams the initial ``/completions`` request. When the run
52
+ terminates with unresolved tool calls, treats them as client-side,
53
+ invokes the registered handler, posts the result via
54
+ ``/tool-result``, and re-streams a continuation request with
55
+ ``tool_continuation=true`` and ``messages=[]``. Loops until no
56
+ pending calls remain or the user explicitly stopped.
57
+ """
58
+ current_body = dict(body)
59
+ while True:
60
+ pending: dict[str, dict[str, Any]] = {}
61
+ stopped = False
62
+ async for event in self._transport.stream_completions(current_body):
63
+ etype = event.get("type", "")
64
+ if etype == "tool-input-available":
65
+ tcid = event.get("toolCallId", "")
66
+ if tcid:
67
+ pending[tcid] = {
68
+ "name": event.get("toolName", ""),
69
+ "input": event.get("input", {}) or {},
70
+ }
71
+ elif etype == "tool-output-available":
72
+ pending.pop(event.get("toolCallId", ""), None)
73
+ elif etype == "data-stopped":
74
+ stopped = True
75
+ yield event
76
+
77
+ if stopped or not pending:
78
+ return
79
+
80
+ for tcid, meta in pending.items():
81
+ handler = self._registry.handler_for(meta["name"])
82
+ result = await handler(meta["input"], ctx)
83
+ await self._transport.submit_tool_result(
84
+ self._chat_id, tcid, meta["name"], result,
85
+ )
86
+
87
+ current_body = {**body, "messages": [], "tool_continuation": True}
88
+
89
+ async def stop(self) -> None:
90
+ """Set the backend stop signal. Idempotent."""
91
+ await self._transport.stop(self._chat_id)
92
+
93
+ async def clear_chat(self) -> None:
94
+ """Delete the chat row on the backend."""
95
+ await self._transport.clear_chat(self._chat_id)
96
+
97
+ async def queue_message(self, content: str) -> str:
98
+ """Add a message to the FIFO queue. Returns the new message id."""
99
+ return await self._transport.queue_message(self._chat_id, content)
100
+
101
+ async def list_queue(self) -> list[dict[str, Any]]:
102
+ """Return the current queued-messages list."""
103
+ return await self._transport.list_queue(self._chat_id)
104
+
105
+ async def edit_queued(self, message_id: str, content: str) -> None:
106
+ """Edit a queued message in place."""
107
+ await self._transport.edit_queued(self._chat_id, message_id, content)
108
+
109
+ async def remove_queued(self, message_id: str) -> None:
110
+ """Remove a queued message."""
111
+ await self._transport.remove_queued(self._chat_id, message_id)
@@ -0,0 +1,131 @@
1
+ """Tool registry, default renderers, and default handlers.
2
+
3
+ There are two extension points per tool:
4
+
5
+ - **Renderer** — called when the agent emits ``tool-input-available``,
6
+ ``tool-output-available``, or ``data-tool-output-delta`` for that tool.
7
+ Returns a Rich renderable (``Panel``, ``Text``, …) or ``None`` to skip.
8
+ Used for server-side tools whose execution happens in the backend.
9
+
10
+ - **Handler** — called when the agent emits ``tool-input-available`` and
11
+ the run terminates without a corresponding ``tool-output-available``
12
+ (= the tool was registered ``client=True`` server-side). Returns the
13
+ result string the next agent turn should see.
14
+
15
+ Defaults:
16
+
17
+ - :func:`default_renderer` — a one-line ``→ tool_name`` indicator on
18
+ ``tool-input-available``; nothing on output / delta events. Custom
19
+ renderers can opt into args, output, or streaming deltas.
20
+ - :func:`default_handler` — prints the input dict, asks for a free-form
21
+ text answer via a one-shot prompt_toolkit prompt.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from dataclasses import dataclass, field
28
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
29
+
30
+ from prompt_toolkit import PromptSession
31
+ from prompt_toolkit.formatted_text import HTML
32
+ from rich.panel import Panel
33
+ from rich.text import Text
34
+
35
+ if TYPE_CHECKING:
36
+ from rich.console import RenderableType
37
+
38
+ from delos_cli.ctx import Ctx
39
+
40
+
41
+ @runtime_checkable
42
+ class ToolRenderer(Protocol):
43
+ """Render one tool-related event.
44
+
45
+ Return a Rich renderable to print above the live region, or ``None``
46
+ to skip rendering. The same renderer instance receives every event
47
+ for its tool name across the whole REPL session, so it may keep state
48
+ (e.g. dedupe by ``toolCallId``) if needed.
49
+ """
50
+
51
+ def __call__(self, event: dict[str, Any]) -> RenderableType | None:
52
+ """Render ``event`` and return a Rich renderable, or ``None`` to skip."""
53
+ ...
54
+
55
+
56
+ @runtime_checkable
57
+ class ToolHandler(Protocol):
58
+ """Resolve a client-side tool call.
59
+
60
+ Receives the tool's ``input`` dict (whatever the agent passed) and the
61
+ REPL :class:`~delos_cli.ctx.Ctx` for console / HTTP access. Returns
62
+ the result string the next agent turn should see.
63
+ """
64
+
65
+ async def __call__(
66
+ self, tool_input: dict[str, Any], ctx: Ctx,
67
+ ) -> str:
68
+ """Collect input from the user and return the result string."""
69
+ ...
70
+
71
+
72
+ def default_renderer(event: dict[str, Any]) -> RenderableType | None:
73
+ """Show a single ``→ tool_name`` line on ``tool-input-available``; ignore the rest.
74
+
75
+ Args are intentionally hidden — most tool calls are noisy enough as JSON
76
+ that they hurt readability for the default case. Custom renderers can
77
+ opt into args, output (``tool-output-available``), and streaming
78
+ deltas (``data-tool-output-delta``).
79
+ """
80
+ if event.get("type") != "tool-input-available":
81
+ return None
82
+ name = event.get("toolName", "?")
83
+ line = Text()
84
+ line.append("→ ", style="bold cyan")
85
+ line.append(name, style="cyan")
86
+ return line
87
+
88
+
89
+ async def default_handler(tool_input: dict[str, Any], ctx: Ctx) -> str:
90
+ """Print the input dict and prompt for a single-line free-form answer.
91
+
92
+ A one-shot :class:`PromptSession` is spun up so the user gets the same
93
+ keybindings as the main REPL (Ctrl+R search, history hints, …) without
94
+ polluting the chat history file with these tool answers.
95
+ """
96
+ try:
97
+ pretty = json.dumps(tool_input, indent=2, ensure_ascii=False)
98
+ except (TypeError, ValueError):
99
+ pretty = str(tool_input)
100
+ ctx.console.print(
101
+ Panel(
102
+ Text(pretty, style="dim"),
103
+ title="? client tool input",
104
+ title_align="left",
105
+ border_style="yellow",
106
+ expand=False,
107
+ ),
108
+ )
109
+ sess: PromptSession[str] = PromptSession()
110
+ answer = await sess.prompt_async(HTML("<ansiyellow>tool result › </ansiyellow>"))
111
+ return answer.strip()
112
+
113
+
114
+ @dataclass
115
+ class ToolRegistry:
116
+ """Per-tool overrides keyed by tool name.
117
+
118
+ Lookups fall back to :func:`default_renderer` / :func:`default_handler`
119
+ when a tool isn't registered.
120
+ """
121
+
122
+ renderers: dict[str, ToolRenderer] = field(default_factory=dict)
123
+ handlers: dict[str, ToolHandler] = field(default_factory=dict)
124
+
125
+ def renderer_for(self, name: str) -> ToolRenderer:
126
+ """Return the registered renderer for ``name``, or the default."""
127
+ return self.renderers.get(name, default_renderer)
128
+
129
+ def handler_for(self, name: str) -> ToolHandler:
130
+ """Return the registered handler for ``name``, or the default."""
131
+ return self.handlers.get(name, default_handler)
@@ -0,0 +1,102 @@
1
+ """HTTP wrappers for the ``/{app}/agent/*`` routes.
2
+
3
+ One :class:`AgentTransport` instance is bound to ``(app, org)``; the
4
+ ``chat_id`` is passed per call. Methods map 1:1 to the routes the backend
5
+ exposes via ``create_agent_router``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import AsyncIterator
15
+
16
+ from delos_cli.transport.client import AuthedClient
17
+
18
+
19
+ class AgentTransport:
20
+ """One transport instance per ``(app, org)`` pair."""
21
+
22
+ def __init__(
23
+ self,
24
+ http: AuthedClient,
25
+ api_prefix: str,
26
+ org_uuid: str,
27
+ ) -> None:
28
+ """Bind to an app's agent router.
29
+
30
+ ``api_prefix`` is the app slug (``"chat"``, ``"code"``, …) — it is
31
+ what the backend uses to mount each ``create_agent_router`` instance.
32
+ """
33
+ self._http = http
34
+ self._prefix = api_prefix
35
+ self._org = org_uuid
36
+
37
+ def _path(self, suffix: str, chat_id: str | None = None) -> str:
38
+ base = f"/{self._prefix}/agent/{suffix}/{self._org}"
39
+ return f"{base}/{chat_id}" if chat_id is not None else base
40
+
41
+ async def stream_completions(
42
+ self, body: dict[str, Any],
43
+ ) -> AsyncIterator[dict[str, Any]]:
44
+ """``POST /{app}/agent/completions/{org}`` → parsed SSE events."""
45
+ async for event in self._http.sse_post(self._path("completions"), body):
46
+ yield event
47
+
48
+ async def stop(self, chat_id: str) -> None:
49
+ """``POST /stop/{org}/{chat_id}`` — set the Redis stop signal."""
50
+ await self._http.json_post(self._path("stop", chat_id), {})
51
+
52
+ async def submit_tool_result(
53
+ self,
54
+ chat_id: str,
55
+ tool_call_id: str,
56
+ tool_name: str,
57
+ content: str,
58
+ ) -> None:
59
+ """``POST /tool-result/{org}/{chat_id}`` — persist a client-tool result."""
60
+ await self._http.json_post(
61
+ self._path("tool-result", chat_id),
62
+ {
63
+ "tool_call_id": tool_call_id,
64
+ "tool_name": tool_name,
65
+ "content": content,
66
+ },
67
+ )
68
+
69
+ async def clear_chat(self, chat_id: str) -> None:
70
+ """``POST /clear-chat/{org}/{chat_id}`` — delete the chat row."""
71
+ await self._http.json_post(self._path("clear-chat", chat_id), {})
72
+
73
+ async def queue_message(self, chat_id: str, content: str) -> str:
74
+ """``POST /queue/{org}/{chat_id}`` — append to the FIFO queue.
75
+
76
+ Generates a fresh UUID client-side (mirrors the frontend) and returns
77
+ it so the caller can later edit / remove the queued entry.
78
+ """
79
+ message_id = str(uuid.uuid4())
80
+ await self._http.json_post(
81
+ self._path("queue", chat_id),
82
+ {"message_id": message_id, "content": content},
83
+ )
84
+ return message_id
85
+
86
+ async def list_queue(self, chat_id: str) -> list[dict[str, Any]]:
87
+ """``GET /queue/{org}/{chat_id}`` — current queue snapshot."""
88
+ data = await self._http.json_get(self._path("queue", chat_id))
89
+ queue = data.get("queue") or []
90
+ return list(queue)
91
+
92
+ async def edit_queued(
93
+ self, chat_id: str, message_id: str, content: str,
94
+ ) -> None:
95
+ """``PUT /queue/{org}/{chat_id}/{msg_id}``."""
96
+ path = f"{self._path('queue', chat_id)}/{message_id}"
97
+ await self._http.json_request("PUT", path, {"content": content})
98
+
99
+ async def remove_queued(self, chat_id: str, message_id: str) -> None:
100
+ """``DELETE /queue/{org}/{chat_id}/{msg_id}``."""
101
+ path = f"{self._path('queue', chat_id)}/{message_id}"
102
+ await self._http.json_request("DELETE", path, None)
@@ -0,0 +1,6 @@
1
+ """Apps: one agent per module, all implementing :class:`~delos_cli.apps.base.App`."""
2
+
3
+ from .base import App, Renderer
4
+ from .chat import ChatApp
5
+
6
+ __all__ = ["App", "ChatApp", "Renderer"]
delos_cli/apps/base.py ADDED
@@ -0,0 +1,101 @@
1
+ """App + Renderer protocols.
2
+
3
+ Each app (chat, scribe, code, …) implements ``App``. The loop is
4
+ app-agnostic: it only knows how to send a turn, get a renderer, and
5
+ ask for a toolbar fragment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import AsyncIterator
15
+
16
+ from delos_cli.commands.base import CommandSpec
17
+ from delos_cli.ctx import Ctx
18
+ from delos_cli.ui.output import OutputBuffer
19
+
20
+
21
+ @runtime_checkable
22
+ class Renderer(Protocol):
23
+ """Consumes streamed events and pushes them into the REPL's :class:`OutputBuffer`.
24
+
25
+ The renderer no longer prints to a console directly — the
26
+ :class:`~prompt_toolkit.application.Application` owns the actual
27
+ terminal drawing. Renderers only translate v6 events into block /
28
+ live updates on the buffer.
29
+ """
30
+
31
+ assistant_text: str
32
+
33
+ def apply(self, event: dict[str, Any]) -> None:
34
+ """Handle one streamed event."""
35
+ ...
36
+
37
+ def close(self) -> None:
38
+ """Finalize any in-flight live block — call at the end of a turn."""
39
+ ...
40
+
41
+
42
+ class App(ABC):
43
+ """Interface one backend agent needs to implement to plug into the REPL.
44
+
45
+ Subclasses own their own state (model choice, selected tools, …).
46
+ """
47
+
48
+ #: Short user-facing name (``"chat"``, ``"scribe"``, …).
49
+ name: str
50
+
51
+ #: Slash commands specific to this app — merged with the global registry.
52
+ commands: dict[str, CommandSpec]
53
+
54
+ @abstractmethod
55
+ async def on_enter(self, ctx: Ctx) -> None:
56
+ """Hook called once, on REPL startup.
57
+
58
+ Prefetch lists (e.g. available models) or rehydrate state before
59
+ the loop starts reading input. Implement as ``pass`` if nothing
60
+ is needed.
61
+ """
62
+
63
+ @abstractmethod
64
+ def send(
65
+ self,
66
+ ctx: Ctx,
67
+ messages: list[dict[str, str]],
68
+ ) -> AsyncIterator[dict[str, Any]]:
69
+ """Stream one turn's events. The loop will ``async for`` over this."""
70
+
71
+ @abstractmethod
72
+ def make_renderer(self, output: OutputBuffer) -> Renderer:
73
+ """Build a fresh renderer bound to ``output`` for this turn."""
74
+
75
+ def toolbar_fragment(self, ctx: Ctx) -> str:
76
+ """Optional HTML fragment the bottom toolbar appends after the shared bits.
77
+
78
+ Default: empty. Override to show app-specific state (selected model,
79
+ open document, …).
80
+ """
81
+ _ = self, ctx
82
+ return ""
83
+
84
+ async def cancel(self, ctx: Ctx) -> None:
85
+ """Hook called on Ctrl+C during a turn or when ``/stop`` is invoked.
86
+
87
+ Default is a no-op. Apps that drive a backend agent should override
88
+ this to send the corresponding stop signal so the run aborts on the
89
+ server, not just locally.
90
+ """
91
+ _ = self, ctx
92
+
93
+ async def queue_message(self, ctx: Ctx, content: str) -> bool:
94
+ """Queue a plain message while a turn is already in flight.
95
+
96
+ Default returns ``False`` (caller should fall back to refusing /
97
+ ignoring). Apps that wrap a real agent should append to its
98
+ backend FIFO queue and return ``True`` on success.
99
+ """
100
+ _ = self, ctx, content
101
+ return False
@@ -0,0 +1,5 @@
1
+ """Chat app package — default agent."""
2
+
3
+ from .app import ChatApp
4
+
5
+ __all__ = ["ChatApp"]
@@ -0,0 +1,149 @@
1
+ """ChatApp — the delos-cli agent.
2
+
3
+ Wraps an :class:`AgentSession` against the ``/cli/agent/*`` backend
4
+ endpoints + ``app_cli`` / ``app_cli_messages`` Supabase tables. The
5
+ chat row is INSERT'd at picker time (cf. :func:`main._run_repl`), so by
6
+ the time we get here the agent always has a row to append messages to.
7
+
8
+ Owns the per-app :class:`ToolRegistry` (so users can plug in custom
9
+ renderers / handlers per tool). The :class:`V6Renderer` returned from
10
+ :meth:`make_renderer` is wired to the same registry.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ from dataclasses import dataclass, field
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from rich.text import Text
20
+
21
+ from delos_cli.agent import AgentSession, AgentTransport, ToolRegistry
22
+ from delos_cli.apps.base import App
23
+ from delos_cli.tools import (
24
+ handle_edit_content,
25
+ handle_run_shell,
26
+ handle_write_content,
27
+ render_edit_content,
28
+ render_run_shell,
29
+ render_write_content,
30
+ )
31
+ from delos_cli.transport.client import TransportError
32
+ from delos_cli.transport.models import fetch_models
33
+
34
+ from .commands import CHAT_COMMANDS
35
+ from .render import V6Renderer
36
+ from .replay import replay_messages
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import AsyncIterator
40
+
41
+ from delos_cli.commands.base import CommandSpec
42
+ from delos_cli.ctx import Ctx
43
+ from delos_cli.ui.output import OutputBuffer
44
+
45
+
46
+ def _default_tool_registry() -> ToolRegistry:
47
+ """Pre-wired registry: client tools the backend can call out of the box."""
48
+ reg = ToolRegistry()
49
+ reg.handlers["run_shell"] = handle_run_shell
50
+ reg.renderers["run_shell"] = render_run_shell
51
+ reg.handlers["write_content"] = handle_write_content
52
+ reg.renderers["write_content"] = render_write_content
53
+ reg.handlers["edit_content"] = handle_edit_content
54
+ reg.renderers["edit_content"] = render_edit_content
55
+ return reg
56
+
57
+
58
+ @dataclass
59
+ class ChatApp(App):
60
+ """The CLI chat agent — talks to ``/cli/agent/...``."""
61
+
62
+ name: str = "cli"
63
+ available_models: list[str] = field(default_factory=list)
64
+ commands: dict[str, CommandSpec] = field(default_factory=lambda: dict(CHAT_COMMANDS))
65
+ #: Per-tool renderers + handlers. Built-in entries cover the
66
+ #: client-side tools the backend exposes (``run_shell`` for now).
67
+ #: Override / extend by mutating ``tools.handlers`` and
68
+ #: ``tools.renderers`` after construction.
69
+ tools: ToolRegistry = field(default_factory=_default_tool_registry)
70
+ #: OpenAI-style messages staged for replay on the next ``on_enter``.
71
+ #: Set by ``main._run_repl`` after the picker resolves a chat_id, so
72
+ #: history is rendered the moment the REPL takes over the screen.
73
+ pending_replay: list[dict[str, Any]] = field(default_factory=list)
74
+ _current_session: AgentSession | None = field(default=None, init=False, repr=False)
75
+
76
+ async def on_enter(self, ctx: Ctx) -> None:
77
+ """Prefetch model list, then replay any staged conversation history."""
78
+ try:
79
+ self.available_models = await fetch_models(ctx.http)
80
+ except TransportError as e:
81
+ sink = ctx.output if ctx.output is not None else ctx.console
82
+ sink.print(Text(f"(could not fetch models: {e})", style="yellow"))
83
+
84
+ if self.pending_replay and ctx.output is not None:
85
+ replay_messages(self.pending_replay, self, ctx.output)
86
+ # Always drain — even a failed replay shouldn't replay again next time.
87
+ self.pending_replay = []
88
+
89
+ async def send(
90
+ self,
91
+ ctx: Ctx,
92
+ messages: list[dict[str, str]],
93
+ ) -> AsyncIterator[dict[str, Any]]:
94
+ """Stream one turn through :class:`AgentSession` against ``/cli/agent/...``.
95
+
96
+ The ``app_cli`` row is created at picker time (or by ``/clear``),
97
+ so the backend always finds the chat when this runs — no
98
+ ``ensure_chat_row`` dance.
99
+ """
100
+ transport = AgentTransport(ctx.http, "cli", ctx.cfg.org_uuid)
101
+ session = AgentSession(transport, ctx.state.conv_id, self.tools)
102
+
103
+ # Backend reads model + everything else from ``app_cli``; we only
104
+ # send what's strictly per-turn.
105
+ body: dict[str, Any] = {
106
+ "chat_id": ctx.state.conv_id,
107
+ "messages": messages,
108
+ "language": "en",
109
+ }
110
+
111
+ self._current_session = session
112
+ try:
113
+ async for event in session.send_turn(body, ctx):
114
+ yield event
115
+ finally:
116
+ self._current_session = None
117
+
118
+ def make_renderer(self, output: OutputBuffer) -> V6Renderer:
119
+ """Build a fresh :class:`V6Renderer` wired to this app's tool registry."""
120
+ return V6Renderer(output, self.tools)
121
+
122
+ async def cancel(self, ctx: Ctx) -> None:
123
+ """Send the backend stop signal for the in-flight turn, if any.
124
+
125
+ No-op when no turn is active — :meth:`send` clears the session ref
126
+ as soon as the iterator finishes.
127
+ """
128
+ _ = ctx
129
+ if self._current_session is None:
130
+ return
131
+ # Best-effort — the loop has already shown an interrupt message.
132
+ with contextlib.suppress(TransportError):
133
+ await self._current_session.stop()
134
+
135
+ async def queue_message(self, ctx: Ctx, content: str) -> bool:
136
+ """Append a message to the backend's FIFO queue while a turn is running."""
137
+ _ = ctx
138
+ if self._current_session is None:
139
+ return False
140
+ try:
141
+ await self._current_session.queue_message(content)
142
+ except TransportError:
143
+ return False
144
+ return True
145
+
146
+ def toolbar_fragment(self, ctx: Ctx) -> str:
147
+ """Empty for now — model is server-side, no per-app state worth surfacing."""
148
+ _ = self, ctx
149
+ return ""
@@ -0,0 +1,17 @@
1
+ """Chat-specific slash commands.
2
+
3
+ Currently empty — the four global commands (``/help``, ``/clear``,
4
+ ``/stop``, ``/resume``, ``/quit``) cover everything we want exposed to
5
+ end users. Add a new ``CommandSpec`` here and merge it into
6
+ :data:`CHAT_COMMANDS` to wire app-specific actions back in.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from delos_cli.commands.base import CommandSpec
15
+
16
+
17
+ CHAT_COMMANDS: dict[str, CommandSpec] = {}