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.
Files changed (93) hide show
  1. elidia/__init__.py +0 -0
  2. elidia/__main__.py +4 -0
  3. elidia/agent/__init__.py +3 -0
  4. elidia/agent/loop.py +449 -0
  5. elidia/agent/personas.py +130 -0
  6. elidia/agent/portal.py +115 -0
  7. elidia/api/__init__.py +0 -0
  8. elidia/api/client.py +221 -0
  9. elidia/api/streaming.py +55 -0
  10. elidia/auth/__init__.py +0 -0
  11. elidia/auth/keychain.py +86 -0
  12. elidia/cache/__init__.py +1 -0
  13. elidia/cache/lru.py +158 -0
  14. elidia/cli/__init__.py +0 -0
  15. elidia/cli/commands.py +122 -0
  16. elidia/cli/main.py +293 -0
  17. elidia/cli/pager.py +104 -0
  18. elidia/cli/progress.py +173 -0
  19. elidia/cli/renderer.py +32 -0
  20. elidia/cli/repl.py +1234 -0
  21. elidia/cli/themes.py +214 -0
  22. elidia/config/__init__.py +0 -0
  23. elidia/config/defaults.py +5 -0
  24. elidia/config/rules.py +48 -0
  25. elidia/config/settings.py +217 -0
  26. elidia/creative/__init__.py +1 -0
  27. elidia/creative/audio.py +213 -0
  28. elidia/creative/display.py +260 -0
  29. elidia/creative/image.py +142 -0
  30. elidia/creative/local.py +163 -0
  31. elidia/creative/video.py +181 -0
  32. elidia/daemon/__init__.py +0 -0
  33. elidia/daemon/manager.py +349 -0
  34. elidia/db/__init__.py +0 -0
  35. elidia/db/database.py +87 -0
  36. elidia/mcp/__init__.py +0 -0
  37. elidia/mcp/client.py +198 -0
  38. elidia/mcp/config.py +93 -0
  39. elidia/mcp/registry.py +101 -0
  40. elidia/mcp/types.py +43 -0
  41. elidia/memory/__init__.py +3 -0
  42. elidia/memory/auto.py +164 -0
  43. elidia/memory/compaction.py +101 -0
  44. elidia/memory/embeddings.py +47 -0
  45. elidia/memory/outcomes.py +131 -0
  46. elidia/memory/patterns.py +79 -0
  47. elidia/memory/store.py +338 -0
  48. elidia/models/__init__.py +0 -0
  49. elidia/models/adaptive.py +53 -0
  50. elidia/models/router.py +115 -0
  51. elidia/modes/__init__.py +1 -0
  52. elidia/modes/autonomous.py +318 -0
  53. elidia/modes/budget.py +182 -0
  54. elidia/modes/classifier.py +169 -0
  55. elidia/modes/consensus.py +210 -0
  56. elidia/modes/deep_think.py +190 -0
  57. elidia/modes/swarm.py +229 -0
  58. elidia/modes/thinking.py +153 -0
  59. elidia/permissions/__init__.py +0 -0
  60. elidia/permissions/audit.py +138 -0
  61. elidia/permissions/manager.py +199 -0
  62. elidia/permissions/trust.py +63 -0
  63. elidia/rag/__init__.py +4 -0
  64. elidia/rag/chunker.py +216 -0
  65. elidia/rag/engine.py +345 -0
  66. elidia/rag/ingest.py +154 -0
  67. elidia/rag/portal_bridge.py +84 -0
  68. elidia/rag/watcher.py +98 -0
  69. elidia/research/__init__.py +1 -0
  70. elidia/research/export.py +237 -0
  71. elidia/research/orchestrator.py +310 -0
  72. elidia/research/sources.py +223 -0
  73. elidia/session/__init__.py +0 -0
  74. elidia/session/history.py +119 -0
  75. elidia/session/manager.py +82 -0
  76. elidia/tools/__init__.py +28 -0
  77. elidia/tools/base.py +70 -0
  78. elidia/tools/fetch.py +100 -0
  79. elidia/tools/filesystem.py +204 -0
  80. elidia/tools/git.py +156 -0
  81. elidia/tools/search.py +99 -0
  82. elidia/tools/terminal.py +77 -0
  83. elidia/widgets/__init__.py +1 -0
  84. elidia/widgets/protocol.py +248 -0
  85. elidia/widgets/renderer.py +225 -0
  86. elidia/workflow/__init__.py +1 -0
  87. elidia/workflow/engine.py +377 -0
  88. elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
  89. elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
  90. elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  91. elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  92. elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
  93. 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)