elidia-agent-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.
- elidia/__init__.py +0 -0
- elidia/__main__.py +4 -0
- elidia/agent/__init__.py +3 -0
- elidia/agent/loop.py +449 -0
- elidia/agent/personas.py +130 -0
- elidia/agent/portal.py +115 -0
- elidia/api/__init__.py +0 -0
- elidia/api/client.py +221 -0
- elidia/api/streaming.py +55 -0
- elidia/auth/__init__.py +0 -0
- elidia/auth/keychain.py +86 -0
- elidia/cache/__init__.py +1 -0
- elidia/cache/lru.py +158 -0
- elidia/cli/__init__.py +0 -0
- elidia/cli/commands.py +122 -0
- elidia/cli/main.py +293 -0
- elidia/cli/pager.py +104 -0
- elidia/cli/progress.py +173 -0
- elidia/cli/renderer.py +32 -0
- elidia/cli/repl.py +1234 -0
- elidia/cli/themes.py +214 -0
- elidia/config/__init__.py +0 -0
- elidia/config/defaults.py +5 -0
- elidia/config/rules.py +48 -0
- elidia/config/settings.py +217 -0
- elidia/creative/__init__.py +1 -0
- elidia/creative/audio.py +213 -0
- elidia/creative/display.py +260 -0
- elidia/creative/image.py +142 -0
- elidia/creative/local.py +163 -0
- elidia/creative/video.py +181 -0
- elidia/daemon/__init__.py +0 -0
- elidia/daemon/manager.py +349 -0
- elidia/db/__init__.py +0 -0
- elidia/db/database.py +87 -0
- elidia/mcp/__init__.py +0 -0
- elidia/mcp/client.py +198 -0
- elidia/mcp/config.py +93 -0
- elidia/mcp/registry.py +101 -0
- elidia/mcp/types.py +43 -0
- elidia/memory/__init__.py +3 -0
- elidia/memory/auto.py +164 -0
- elidia/memory/compaction.py +101 -0
- elidia/memory/embeddings.py +47 -0
- elidia/memory/outcomes.py +131 -0
- elidia/memory/patterns.py +79 -0
- elidia/memory/store.py +338 -0
- elidia/models/__init__.py +0 -0
- elidia/models/adaptive.py +53 -0
- elidia/models/router.py +115 -0
- elidia/modes/__init__.py +1 -0
- elidia/modes/autonomous.py +318 -0
- elidia/modes/budget.py +182 -0
- elidia/modes/classifier.py +169 -0
- elidia/modes/consensus.py +210 -0
- elidia/modes/deep_think.py +190 -0
- elidia/modes/swarm.py +229 -0
- elidia/modes/thinking.py +153 -0
- elidia/permissions/__init__.py +0 -0
- elidia/permissions/audit.py +138 -0
- elidia/permissions/manager.py +199 -0
- elidia/permissions/trust.py +63 -0
- elidia/rag/__init__.py +4 -0
- elidia/rag/chunker.py +216 -0
- elidia/rag/engine.py +345 -0
- elidia/rag/ingest.py +154 -0
- elidia/rag/portal_bridge.py +84 -0
- elidia/rag/watcher.py +98 -0
- elidia/research/__init__.py +1 -0
- elidia/research/export.py +237 -0
- elidia/research/orchestrator.py +310 -0
- elidia/research/sources.py +223 -0
- elidia/session/__init__.py +0 -0
- elidia/session/history.py +119 -0
- elidia/session/manager.py +82 -0
- elidia/tools/__init__.py +28 -0
- elidia/tools/base.py +70 -0
- elidia/tools/fetch.py +100 -0
- elidia/tools/filesystem.py +204 -0
- elidia/tools/git.py +156 -0
- elidia/tools/search.py +99 -0
- elidia/tools/terminal.py +77 -0
- elidia/widgets/__init__.py +1 -0
- elidia/widgets/protocol.py +248 -0
- elidia/widgets/renderer.py +225 -0
- elidia/workflow/__init__.py +1 -0
- elidia/workflow/engine.py +377 -0
- elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
- elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
- elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
- elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
- elidia_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
elidia/cli/commands.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SlashCommand:
|
|
11
|
+
name: str
|
|
12
|
+
description: str
|
|
13
|
+
usage: str = ""
|
|
14
|
+
handler: Callable[..., Any] | None = None
|
|
15
|
+
category: str = "general"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommandRegistry:
|
|
19
|
+
"""Registry of slash commands available in the REPL."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
logger.debug("Entered into CommandRegistry.__init__")
|
|
23
|
+
self._commands: dict[str, SlashCommand] = {}
|
|
24
|
+
|
|
25
|
+
def register(self, cmd: SlashCommand) -> None:
|
|
26
|
+
logger.debug(f"Entered into register: name={cmd.name}")
|
|
27
|
+
self._commands[cmd.name] = cmd
|
|
28
|
+
|
|
29
|
+
def get(self, name: str) -> SlashCommand | None:
|
|
30
|
+
logger.debug(f"Entered into get: name={name}")
|
|
31
|
+
clean = name.lstrip("/").lower()
|
|
32
|
+
return self._commands.get(clean)
|
|
33
|
+
|
|
34
|
+
def list_commands(self) -> list[SlashCommand]:
|
|
35
|
+
logger.debug("Entered into list_commands")
|
|
36
|
+
return sorted(self._commands.values(), key=lambda c: c.name)
|
|
37
|
+
|
|
38
|
+
def list_by_category(self) -> dict[str, list[SlashCommand]]:
|
|
39
|
+
logger.debug("Entered into list_by_category")
|
|
40
|
+
cats: dict[str, list[SlashCommand]] = {}
|
|
41
|
+
for cmd in self._commands.values():
|
|
42
|
+
cats.setdefault(cmd.category, []).append(cmd)
|
|
43
|
+
for v in cats.values():
|
|
44
|
+
v.sort(key=lambda c: c.name)
|
|
45
|
+
return cats
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_default_commands() -> CommandRegistry:
|
|
49
|
+
logger.debug("Entered into build_default_commands")
|
|
50
|
+
registry = CommandRegistry()
|
|
51
|
+
|
|
52
|
+
registry.register(SlashCommand(
|
|
53
|
+
name="help", description="Show available commands",
|
|
54
|
+
usage="/help [command]", category="general",
|
|
55
|
+
))
|
|
56
|
+
registry.register(SlashCommand(
|
|
57
|
+
name="model", description="Switch or show current model",
|
|
58
|
+
usage="/model <name|auto>", category="general",
|
|
59
|
+
))
|
|
60
|
+
registry.register(SlashCommand(
|
|
61
|
+
name="mode", description="Switch conversation mode",
|
|
62
|
+
usage="/mode <chat|code|research|think|create>", category="general",
|
|
63
|
+
))
|
|
64
|
+
registry.register(SlashCommand(
|
|
65
|
+
name="cost", description="Show session token usage and cost",
|
|
66
|
+
usage="/cost", category="general",
|
|
67
|
+
))
|
|
68
|
+
registry.register(SlashCommand(
|
|
69
|
+
name="new", description="Start a new conversation session",
|
|
70
|
+
usage="/new", category="session",
|
|
71
|
+
))
|
|
72
|
+
registry.register(SlashCommand(
|
|
73
|
+
name="sessions", description="List recent sessions",
|
|
74
|
+
usage="/sessions", category="session",
|
|
75
|
+
))
|
|
76
|
+
registry.register(SlashCommand(
|
|
77
|
+
name="clear", description="Clear current conversation history",
|
|
78
|
+
usage="/clear", category="session",
|
|
79
|
+
))
|
|
80
|
+
registry.register(SlashCommand(
|
|
81
|
+
name="tools", description="List available tools",
|
|
82
|
+
usage="/tools [category]", category="tools",
|
|
83
|
+
))
|
|
84
|
+
registry.register(SlashCommand(
|
|
85
|
+
name="mcp", description="Show MCP server status",
|
|
86
|
+
usage="/mcp [connect|disconnect <name>]", category="tools",
|
|
87
|
+
))
|
|
88
|
+
registry.register(SlashCommand(
|
|
89
|
+
name="persona", description="Switch domain persona",
|
|
90
|
+
usage="/persona <slug|list|off>", category="agent",
|
|
91
|
+
))
|
|
92
|
+
registry.register(SlashCommand(
|
|
93
|
+
name="trust", description="Show progressive trust stats",
|
|
94
|
+
usage="/trust", category="agent",
|
|
95
|
+
))
|
|
96
|
+
registry.register(SlashCommand(
|
|
97
|
+
name="quit", description="Exit Elidia",
|
|
98
|
+
usage="/quit", category="general",
|
|
99
|
+
))
|
|
100
|
+
registry.register(SlashCommand(
|
|
101
|
+
name="exit", description="Exit Elidia",
|
|
102
|
+
usage="/exit", category="general",
|
|
103
|
+
))
|
|
104
|
+
registry.register(SlashCommand(
|
|
105
|
+
name="balance", description="Show API credit balance",
|
|
106
|
+
usage="/balance", category="general",
|
|
107
|
+
))
|
|
108
|
+
registry.register(SlashCommand(
|
|
109
|
+
name="memory", description="View, search, save, or forget memories",
|
|
110
|
+
usage="/memory [list|search <query>|save key=value|forget <key>]",
|
|
111
|
+
category="memory",
|
|
112
|
+
))
|
|
113
|
+
registry.register(SlashCommand(
|
|
114
|
+
name="history", description="Search chat history across sessions",
|
|
115
|
+
usage="/history [search query]", category="session",
|
|
116
|
+
))
|
|
117
|
+
registry.register(SlashCommand(
|
|
118
|
+
name="rules", description="Show project rules (.elidia/rules.md)",
|
|
119
|
+
usage="/rules", category="general",
|
|
120
|
+
))
|
|
121
|
+
|
|
122
|
+
return registry
|
elidia/cli/main.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from elidia.config.defaults import VERSION
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _setup_logging(debug: bool) -> None:
|
|
15
|
+
logger.debug("Entered into _setup_logging")
|
|
16
|
+
level = logging.DEBUG if debug else logging.WARNING
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=level,
|
|
19
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
20
|
+
handlers=[logging.StreamHandler(sys.stderr)],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_DEFAULT_COMMAND_NAME = "__default__"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ElidiaGroup(click.Group):
|
|
28
|
+
"""Group that falls back to a hidden one-shot command for free-form messages.
|
|
29
|
+
|
|
30
|
+
Click has no built-in way to combine subcommands (``auth``, ``config`` ...)
|
|
31
|
+
with a catch-all positional message (``elidia "question"``): declaring a
|
|
32
|
+
``nargs=-1`` argument directly on the group would swallow every token,
|
|
33
|
+
including real subcommand names, before dispatch ever runs. Instead this
|
|
34
|
+
tries normal subcommand resolution first and only falls back to the
|
|
35
|
+
hidden ``__default__`` command — treating the raw args as a
|
|
36
|
+
natural-language message — when nothing matches.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def resolve_command(self, ctx: click.Context, args: list[str]):
|
|
40
|
+
logger.debug(f"Entered into _ElidiaGroup.resolve_command: arg_count={len(args)}")
|
|
41
|
+
try:
|
|
42
|
+
return super().resolve_command(ctx, args)
|
|
43
|
+
except click.UsageError:
|
|
44
|
+
default_cmd = self.commands[_DEFAULT_COMMAND_NAME]
|
|
45
|
+
return _DEFAULT_COMMAND_NAME, default_cmd, args
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@click.group(cls=_ElidiaGroup, invoke_without_command=True)
|
|
49
|
+
@click.option("--model", "-m", default=None, help="Override model selection")
|
|
50
|
+
@click.option("--mode", default="chat", type=click.Choice(["chat", "code", "research", "think", "create"]))
|
|
51
|
+
@click.option("--debug", is_flag=True, help="Enable debug logging")
|
|
52
|
+
@click.option("--version", "-v", is_flag=True, help="Show version")
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def cli(ctx: click.Context, model: str | None, mode: str, debug: bool, version: bool) -> None:
|
|
55
|
+
"""Elidia — Universal AI Agent for your terminal."""
|
|
56
|
+
_setup_logging(debug)
|
|
57
|
+
logger.debug("Entered into cli")
|
|
58
|
+
|
|
59
|
+
if version:
|
|
60
|
+
console.print(f"elidia-cli v{VERSION}")
|
|
61
|
+
ctx.exit()
|
|
62
|
+
|
|
63
|
+
ctx.ensure_object(dict)
|
|
64
|
+
ctx.obj["model"] = model
|
|
65
|
+
ctx.obj["mode"] = mode
|
|
66
|
+
ctx.obj["debug"] = debug
|
|
67
|
+
|
|
68
|
+
if ctx.invoked_subcommand is not None:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
if not sys.stdin.isatty():
|
|
72
|
+
stdin_content = sys.stdin.read().strip()
|
|
73
|
+
if stdin_content:
|
|
74
|
+
asyncio.run(_one_shot(stdin_content, model=model, mode=mode))
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
asyncio.run(_start_repl(model=model, mode=mode))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@click.command(name=_DEFAULT_COMMAND_NAME, hidden=True)
|
|
81
|
+
@click.argument("message", nargs=-1)
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def _default_message(ctx: click.Context, message: tuple[str, ...]) -> None:
|
|
84
|
+
"""Hidden fallback: any input that isn't a known subcommand is a one-shot message."""
|
|
85
|
+
logger.debug(f"Entered into _default_message: token_count={len(message)}")
|
|
86
|
+
user_msg = " ".join(message).strip()
|
|
87
|
+
if not user_msg:
|
|
88
|
+
return
|
|
89
|
+
model = ctx.obj.get("model") if ctx.obj else None
|
|
90
|
+
mode = ctx.obj.get("mode", "chat") if ctx.obj else "chat"
|
|
91
|
+
asyncio.run(_one_shot(user_msg, model=model, mode=mode))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
cli.add_command(_default_message)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@cli.command("ask")
|
|
98
|
+
@click.argument("message", nargs=-1, required=True)
|
|
99
|
+
@click.option("--model", "-m", default=None, help="Override model selection")
|
|
100
|
+
@click.option("--mode", default=None, type=click.Choice(["chat", "code", "research", "think", "create"]))
|
|
101
|
+
@click.pass_context
|
|
102
|
+
def ask(ctx: click.Context, message: tuple[str, ...], model: str | None, mode: str | None) -> None:
|
|
103
|
+
"""Send a one-shot message: elidia ask 'your question here'"""
|
|
104
|
+
logger.debug("Entered into ask")
|
|
105
|
+
parent = ctx.parent
|
|
106
|
+
parent_obj = parent.obj if parent and parent.obj else {}
|
|
107
|
+
model = model or parent_obj.get("model")
|
|
108
|
+
mode = mode or parent_obj.get("mode") or "chat"
|
|
109
|
+
user_msg = " ".join(message)
|
|
110
|
+
asyncio.run(_one_shot(user_msg, model=model, mode=mode))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _one_shot(message: str, model: str | None = None, mode: str = "chat") -> None:
|
|
114
|
+
logger.debug(f"Entered into _one_shot: msg_len={len(message)}")
|
|
115
|
+
from elidia.cli.repl import ElidiaRepl
|
|
116
|
+
|
|
117
|
+
repl = ElidiaRepl(forced_model=model, mode=mode)
|
|
118
|
+
await repl.initialize()
|
|
119
|
+
await repl.send_message(message, interactive=False)
|
|
120
|
+
await repl.cleanup()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _start_repl(model: str | None = None, mode: str = "chat") -> None:
|
|
124
|
+
logger.debug("Entered into _start_repl")
|
|
125
|
+
from elidia.cli.repl import ElidiaRepl
|
|
126
|
+
|
|
127
|
+
repl = ElidiaRepl(forced_model=model, mode=mode)
|
|
128
|
+
await repl.initialize()
|
|
129
|
+
await repl.run()
|
|
130
|
+
await repl.cleanup()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --- Auth subcommands ---
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cli.group()
|
|
137
|
+
def auth() -> None:
|
|
138
|
+
"""Manage API key authentication."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@auth.command("login")
|
|
142
|
+
def auth_login() -> None:
|
|
143
|
+
"""Store your AiUtils API key."""
|
|
144
|
+
logger.debug("Entered into auth_login")
|
|
145
|
+
from elidia.auth.keychain import mask_api_key, store_api_key, validate_api_key
|
|
146
|
+
|
|
147
|
+
key = click.prompt("Enter your AiUtils API key (get one at developer.aiutils.io)", hide_input=True)
|
|
148
|
+
if not validate_api_key(key):
|
|
149
|
+
console.print("[red]Invalid key format. Keys start with 'ak-dev-'.[/red]")
|
|
150
|
+
raise SystemExit(1)
|
|
151
|
+
store_api_key(key)
|
|
152
|
+
console.print(f"[green]v[/green] API key stored: {mask_api_key(key)}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@auth.command("logout")
|
|
156
|
+
def auth_logout() -> None:
|
|
157
|
+
"""Remove stored API key."""
|
|
158
|
+
logger.debug("Entered into auth_logout")
|
|
159
|
+
from elidia.auth.keychain import delete_api_key
|
|
160
|
+
|
|
161
|
+
delete_api_key()
|
|
162
|
+
console.print("[green]v[/green] API key removed.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@auth.command("status")
|
|
166
|
+
def auth_status() -> None:
|
|
167
|
+
"""Show API key status and balance."""
|
|
168
|
+
logger.debug("Entered into auth_status")
|
|
169
|
+
from elidia.auth.keychain import get_api_key, mask_api_key
|
|
170
|
+
|
|
171
|
+
key = get_api_key()
|
|
172
|
+
if not key:
|
|
173
|
+
console.print("[yellow]No API key configured.[/yellow] Run: elidia auth login")
|
|
174
|
+
return
|
|
175
|
+
console.print(f"API Key: {mask_api_key(key)}")
|
|
176
|
+
|
|
177
|
+
async def _show_balance() -> None:
|
|
178
|
+
from elidia.api.client import AiUtilsClient
|
|
179
|
+
from elidia.config.settings import load_config
|
|
180
|
+
|
|
181
|
+
config = load_config()
|
|
182
|
+
client = AiUtilsClient(api_key=key, base_url=config.api.base_url)
|
|
183
|
+
balance = await client.get_balance()
|
|
184
|
+
await client.close()
|
|
185
|
+
if balance.get("balance_dt", -1) >= 0:
|
|
186
|
+
console.print(f"Balance: {balance['balance_dt']:,.0f} DT (${balance['balance_dt'] / 1000:.2f})")
|
|
187
|
+
else:
|
|
188
|
+
console.print(f"[yellow]Could not fetch balance: {balance.get('error', 'unknown')}[/yellow]")
|
|
189
|
+
|
|
190
|
+
asyncio.run(_show_balance())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- Config subcommands ---
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.group()
|
|
197
|
+
def config() -> None:
|
|
198
|
+
"""View and manage configuration."""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@config.command("show")
|
|
202
|
+
def config_show() -> None:
|
|
203
|
+
"""Show effective configuration."""
|
|
204
|
+
logger.debug("Entered into config_show")
|
|
205
|
+
import dataclasses
|
|
206
|
+
import json
|
|
207
|
+
|
|
208
|
+
from elidia.auth.keychain import mask_api_key
|
|
209
|
+
from elidia.config.settings import ELIDIA_HOME, load_config
|
|
210
|
+
|
|
211
|
+
cfg = load_config()
|
|
212
|
+
d = dataclasses.asdict(cfg)
|
|
213
|
+
if d.get("api", {}).get("key"):
|
|
214
|
+
d["api"]["key"] = mask_api_key(d["api"]["key"])
|
|
215
|
+
console.print_json(json.dumps(d, indent=2))
|
|
216
|
+
console.print(f"\n[dim]Config dir: {ELIDIA_HOME}[/dim]")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --- Session subcommands ---
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@cli.group()
|
|
223
|
+
def session() -> None:
|
|
224
|
+
"""Manage chat sessions."""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@session.command("list")
|
|
228
|
+
def session_list() -> None:
|
|
229
|
+
"""List recent sessions."""
|
|
230
|
+
logger.debug("Entered into session_list")
|
|
231
|
+
from rich.table import Table
|
|
232
|
+
|
|
233
|
+
from elidia.db.database import Database
|
|
234
|
+
from elidia.session.manager import SessionManager
|
|
235
|
+
|
|
236
|
+
db = Database()
|
|
237
|
+
db.connect()
|
|
238
|
+
mgr = SessionManager(db)
|
|
239
|
+
sessions = mgr.list_sessions()
|
|
240
|
+
db.close()
|
|
241
|
+
|
|
242
|
+
if not sessions:
|
|
243
|
+
console.print("[dim]No sessions yet. Start chatting to create one.[/dim]")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
table = Table(title="Sessions")
|
|
247
|
+
table.add_column("ID", style="dim", max_width=8)
|
|
248
|
+
table.add_column("Title")
|
|
249
|
+
table.add_column("Model", style="cyan")
|
|
250
|
+
table.add_column("Updated", style="green")
|
|
251
|
+
|
|
252
|
+
for s in sessions:
|
|
253
|
+
table.add_row(s["id"][:8], s["title"], s.get("model") or "auto", s["updated_at"])
|
|
254
|
+
|
|
255
|
+
console.print(table)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@session.command("new")
|
|
259
|
+
def session_new() -> None:
|
|
260
|
+
"""Create a new session."""
|
|
261
|
+
logger.debug("Entered into session_new")
|
|
262
|
+
from elidia.db.database import Database
|
|
263
|
+
from elidia.session.manager import SessionManager
|
|
264
|
+
|
|
265
|
+
db = Database()
|
|
266
|
+
db.connect()
|
|
267
|
+
mgr = SessionManager(db)
|
|
268
|
+
sid = mgr.create_session()
|
|
269
|
+
db.close()
|
|
270
|
+
console.print(f"[green]v[/green] Created session: {sid[:8]}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@session.command("delete")
|
|
274
|
+
@click.argument("session_id")
|
|
275
|
+
def session_delete(session_id: str) -> None:
|
|
276
|
+
"""Delete a session."""
|
|
277
|
+
logger.debug(f"Entered into session_delete: {session_id}")
|
|
278
|
+
from elidia.db.database import Database
|
|
279
|
+
from elidia.session.manager import SessionManager
|
|
280
|
+
|
|
281
|
+
db = Database()
|
|
282
|
+
db.connect()
|
|
283
|
+
mgr = SessionManager(db)
|
|
284
|
+
mgr.delete_session(session_id)
|
|
285
|
+
db.close()
|
|
286
|
+
console.print("[green]v[/green] Deleted session.")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@cli.command("version")
|
|
290
|
+
def show_version() -> None:
|
|
291
|
+
"""Show version information."""
|
|
292
|
+
logger.debug("Entered into show_version")
|
|
293
|
+
console.print(f"elidia-cli v{VERSION}")
|
elidia/cli/pager.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Auto-pager — pages long output through the terminal pager.
|
|
2
|
+
|
|
3
|
+
When output exceeds the terminal height, it is automatically routed
|
|
4
|
+
through Rich's built-in pager (which uses the system's PAGER env var,
|
|
5
|
+
defaulting to 'less -R' on Unix or 'more' on Windows).
|
|
6
|
+
|
|
7
|
+
The pager is only engaged for non-interactive, non-streaming content.
|
|
8
|
+
Streaming responses are always rendered directly.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markdown import Markdown
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
DEFAULT_PAGER_THRESHOLD = 0.8
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AutoPager:
|
|
25
|
+
"""Automatically pages output when it exceeds terminal height."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
console: Console | None = None,
|
|
30
|
+
threshold: float = DEFAULT_PAGER_THRESHOLD,
|
|
31
|
+
enabled: bool = True,
|
|
32
|
+
) -> None:
|
|
33
|
+
logger.debug(f"Entered into AutoPager.__init__: threshold={threshold}, enabled={enabled}")
|
|
34
|
+
self._console = console or Console()
|
|
35
|
+
self._threshold = threshold
|
|
36
|
+
self._enabled = enabled
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def enabled(self) -> bool:
|
|
40
|
+
return self._enabled
|
|
41
|
+
|
|
42
|
+
@enabled.setter
|
|
43
|
+
def enabled(self, value: bool) -> None:
|
|
44
|
+
self._enabled = value
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def threshold(self) -> float:
|
|
48
|
+
return self._threshold
|
|
49
|
+
|
|
50
|
+
def get_terminal_height(self) -> int:
|
|
51
|
+
logger.debug("Entered into get_terminal_height")
|
|
52
|
+
size = shutil.get_terminal_size(fallback=(80, 24))
|
|
53
|
+
return size.lines
|
|
54
|
+
|
|
55
|
+
def should_page(self, content: str) -> bool:
|
|
56
|
+
logger.debug("Entered into should_page")
|
|
57
|
+
if not self._enabled:
|
|
58
|
+
return False
|
|
59
|
+
if not content:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
term_height = self.get_terminal_height()
|
|
63
|
+
content_lines = content.count("\n") + 1
|
|
64
|
+
threshold_lines = int(term_height * self._threshold)
|
|
65
|
+
|
|
66
|
+
return content_lines > threshold_lines
|
|
67
|
+
|
|
68
|
+
def print_or_page(self, content: str, as_markdown: bool = True) -> None:
|
|
69
|
+
logger.debug(f"Entered into print_or_page: len={len(content)}, md={as_markdown}")
|
|
70
|
+
if self.should_page(content):
|
|
71
|
+
self._page(content, as_markdown)
|
|
72
|
+
else:
|
|
73
|
+
self._print(content, as_markdown)
|
|
74
|
+
|
|
75
|
+
def _print(self, content: str, as_markdown: bool) -> None:
|
|
76
|
+
logger.debug("Entered into _print")
|
|
77
|
+
if as_markdown:
|
|
78
|
+
try:
|
|
79
|
+
self._console.print(Markdown(content))
|
|
80
|
+
except Exception:
|
|
81
|
+
self._console.print(content)
|
|
82
|
+
else:
|
|
83
|
+
self._console.print(content)
|
|
84
|
+
|
|
85
|
+
def _page(self, content: str, as_markdown: bool) -> None:
|
|
86
|
+
logger.debug("Entered into _page")
|
|
87
|
+
try:
|
|
88
|
+
with self._console.pager(styles=as_markdown):
|
|
89
|
+
if as_markdown:
|
|
90
|
+
self._console.print(Markdown(content))
|
|
91
|
+
else:
|
|
92
|
+
self._console.print(content)
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.warning(f"Pager failed, falling back to direct print: {e}")
|
|
95
|
+
self._print(content, as_markdown)
|
|
96
|
+
|
|
97
|
+
def page_lines(self, lines: list[str]) -> None:
|
|
98
|
+
logger.debug(f"Entered into page_lines: count={len(lines)}")
|
|
99
|
+
content = "\n".join(lines)
|
|
100
|
+
self.print_or_page(content, as_markdown=False)
|
|
101
|
+
|
|
102
|
+
def get_pager_command(self) -> str:
|
|
103
|
+
logger.debug("Entered into get_pager_command")
|
|
104
|
+
return os.environ.get("PAGER", "less -R")
|
elidia/cli/progress.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Progress indicators — spinners and progress bars for async operations.
|
|
2
|
+
|
|
3
|
+
Provides reusable wrappers around Rich's progress display components:
|
|
4
|
+
- Spinner: for indeterminate-length operations (LLM generation, search)
|
|
5
|
+
- ProgressBar: for determinate operations (file download, batch processing)
|
|
6
|
+
- StageTracker: for multi-stage pipelines (research, workflow)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import AsyncIterator, Iterator
|
|
13
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.live import Live
|
|
17
|
+
from rich.progress import (
|
|
18
|
+
BarColumn,
|
|
19
|
+
MofNCompleteColumn,
|
|
20
|
+
Progress,
|
|
21
|
+
SpinnerColumn,
|
|
22
|
+
TaskID,
|
|
23
|
+
TextColumn,
|
|
24
|
+
TimeElapsedColumn,
|
|
25
|
+
TimeRemainingColumn,
|
|
26
|
+
)
|
|
27
|
+
from rich.spinner import Spinner
|
|
28
|
+
from rich.text import Text
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def spinner(
|
|
35
|
+
console: Console,
|
|
36
|
+
message: str = "Working...",
|
|
37
|
+
spinner_name: str = "dots",
|
|
38
|
+
style: str = "cyan",
|
|
39
|
+
) -> Iterator[Live]:
|
|
40
|
+
logger.debug(f"Entered into spinner: message={message}")
|
|
41
|
+
display = Text.assemble(
|
|
42
|
+
(" ", ""),
|
|
43
|
+
(message, style),
|
|
44
|
+
)
|
|
45
|
+
spin = Spinner(spinner_name, text=display, style=style)
|
|
46
|
+
with Live(spin, console=console, transient=True, refresh_per_second=12) as live:
|
|
47
|
+
yield live
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@asynccontextmanager
|
|
51
|
+
async def async_spinner(
|
|
52
|
+
console: Console,
|
|
53
|
+
message: str = "Working...",
|
|
54
|
+
spinner_name: str = "dots",
|
|
55
|
+
style: str = "cyan",
|
|
56
|
+
) -> AsyncIterator[Live]:
|
|
57
|
+
logger.debug(f"Entered into async_spinner: message={message}")
|
|
58
|
+
display = Text.assemble(
|
|
59
|
+
(" ", ""),
|
|
60
|
+
(message, style),
|
|
61
|
+
)
|
|
62
|
+
spin = Spinner(spinner_name, text=display, style=style)
|
|
63
|
+
with Live(spin, console=console, transient=True, refresh_per_second=12) as live:
|
|
64
|
+
yield live
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_progress_bar(
|
|
68
|
+
console: Console,
|
|
69
|
+
transient: bool = True,
|
|
70
|
+
) -> Progress:
|
|
71
|
+
logger.debug("Entered into create_progress_bar")
|
|
72
|
+
return Progress(
|
|
73
|
+
SpinnerColumn(),
|
|
74
|
+
TextColumn("[progress.description]{task.description}"),
|
|
75
|
+
BarColumn(bar_width=30),
|
|
76
|
+
MofNCompleteColumn(),
|
|
77
|
+
TimeElapsedColumn(),
|
|
78
|
+
TimeRemainingColumn(),
|
|
79
|
+
console=console,
|
|
80
|
+
transient=transient,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StageTracker:
|
|
85
|
+
"""Tracks progress through a multi-stage pipeline."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, console: Console, stages: list[str]) -> None:
|
|
88
|
+
logger.debug(f"Entered into StageTracker.__init__: stages={len(stages)}")
|
|
89
|
+
self._console = console
|
|
90
|
+
self._stages = stages
|
|
91
|
+
self._current: int = -1
|
|
92
|
+
self._completed: list[bool] = [False] * len(stages)
|
|
93
|
+
self._timings: list[float] = [0.0] * len(stages)
|
|
94
|
+
self._start_time: float = 0.0
|
|
95
|
+
|
|
96
|
+
def start(self) -> None:
|
|
97
|
+
logger.debug("Entered into StageTracker.start")
|
|
98
|
+
self._start_time = time.monotonic()
|
|
99
|
+
|
|
100
|
+
def advance(self, stage_index: int | None = None) -> None:
|
|
101
|
+
logger.debug(f"Entered into StageTracker.advance: index={stage_index}")
|
|
102
|
+
if self._current >= 0 and self._current < len(self._stages):
|
|
103
|
+
self._completed[self._current] = True
|
|
104
|
+
self._timings[self._current] = time.monotonic() - self._start_time
|
|
105
|
+
|
|
106
|
+
if stage_index is not None:
|
|
107
|
+
self._current = stage_index
|
|
108
|
+
else:
|
|
109
|
+
self._current += 1
|
|
110
|
+
|
|
111
|
+
if self._current < len(self._stages):
|
|
112
|
+
self._start_time = time.monotonic()
|
|
113
|
+
name = self._stages[self._current]
|
|
114
|
+
progress = f"[{self._current + 1}/{len(self._stages)}]"
|
|
115
|
+
self._console.print(f" [cyan]{progress}[/cyan] {name}")
|
|
116
|
+
|
|
117
|
+
def complete(self) -> None:
|
|
118
|
+
logger.debug("Entered into StageTracker.complete")
|
|
119
|
+
if self._current >= 0 and self._current < len(self._stages):
|
|
120
|
+
self._completed[self._current] = True
|
|
121
|
+
self._timings[self._current] = time.monotonic() - self._start_time
|
|
122
|
+
|
|
123
|
+
def render_summary(self) -> None:
|
|
124
|
+
logger.debug("Entered into StageTracker.render_summary")
|
|
125
|
+
total_time = sum(self._timings)
|
|
126
|
+
completed = sum(1 for c in self._completed if c)
|
|
127
|
+
self._console.print(
|
|
128
|
+
f"\n[bold]Pipeline:[/bold] {completed}/{len(self._stages)} stages "
|
|
129
|
+
f"({total_time:.1f}s)"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def current_stage(self) -> str | None:
|
|
134
|
+
if 0 <= self._current < len(self._stages):
|
|
135
|
+
return self._stages[self._current]
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def is_complete(self) -> bool:
|
|
140
|
+
return all(self._completed)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class DownloadTracker:
|
|
144
|
+
"""Tracks file download progress."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, console: Console) -> None:
|
|
147
|
+
logger.debug("Entered into DownloadTracker.__init__")
|
|
148
|
+
self._console = console
|
|
149
|
+
self._progress: Progress | None = None
|
|
150
|
+
self._task_id: TaskID | None = None
|
|
151
|
+
|
|
152
|
+
def start(self, filename: str, total_bytes: int) -> Progress:
|
|
153
|
+
logger.debug(f"Entered into DownloadTracker.start: file={filename}, bytes={total_bytes}")
|
|
154
|
+
self._progress = Progress(
|
|
155
|
+
SpinnerColumn(),
|
|
156
|
+
TextColumn("[progress.description]{task.description}"),
|
|
157
|
+
BarColumn(bar_width=30),
|
|
158
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
159
|
+
TimeElapsedColumn(),
|
|
160
|
+
console=self._console,
|
|
161
|
+
transient=True,
|
|
162
|
+
)
|
|
163
|
+
self._task_id = self._progress.add_task(filename, total=total_bytes)
|
|
164
|
+
return self._progress
|
|
165
|
+
|
|
166
|
+
def update(self, bytes_downloaded: int) -> None:
|
|
167
|
+
if self._progress and self._task_id is not None:
|
|
168
|
+
self._progress.update(self._task_id, completed=bytes_downloaded)
|
|
169
|
+
|
|
170
|
+
def finish(self) -> None:
|
|
171
|
+
logger.debug("Entered into DownloadTracker.finish")
|
|
172
|
+
if self._progress and self._task_id is not None:
|
|
173
|
+
self._progress.update(self._task_id, completed=self._progress.tasks[0].total or 0)
|