zai-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.
- zai/__init__.py +1 -0
- zai/__main__.py +4 -0
- zai/cli/__init__.py +1 -0
- zai/cli/common.py +16 -0
- zai/cli/integrations.py +319 -0
- zai/cli/interactive.py +518 -0
- zai/cli/settings.py +436 -0
- zai/cli/utilities.py +227 -0
- zai/cli/workflows.py +137 -0
- zai/commands/commit.md +24 -0
- zai/commands/explain.md +17 -0
- zai/commands/feature.md +34 -0
- zai/commands/fix.md +14 -0
- zai/commands/review.md +22 -0
- zai/config.py +307 -0
- zai/core/__init__.py +0 -0
- zai/core/agent.py +701 -0
- zai/core/cancellation.py +67 -0
- zai/core/commands.py +85 -0
- zai/core/context.py +299 -0
- zai/core/errors.py +125 -0
- zai/core/fallback.py +171 -0
- zai/core/hooks.py +115 -0
- zai/core/memory.py +57 -0
- zai/core/process.py +204 -0
- zai/core/repomap.py +381 -0
- zai/core/runtime.py +29 -0
- zai/core/security.py +33 -0
- zai/core/session.py +425 -0
- zai/core/storage.py +193 -0
- zai/core/streaming.py +157 -0
- zai/core/tool_schema.py +133 -0
- zai/core/undo.py +443 -0
- zai/core/watch.py +80 -0
- zai/main.py +210 -0
- zai/mcp/__init__.py +0 -0
- zai/mcp/client.py +431 -0
- zai/mcp/manager.py +118 -0
- zai/plugins/__init__.py +2 -0
- zai/plugins/base.py +49 -0
- zai/plugins/loader.py +404 -0
- zai/providers/__init__.py +22 -0
- zai/providers/anthropic.py +131 -0
- zai/providers/base.py +67 -0
- zai/providers/cerebras.py +57 -0
- zai/providers/gemini.py +119 -0
- zai/providers/groq.py +116 -0
- zai/providers/ollama.py +62 -0
- zai/providers/openai.py +124 -0
- zai/providers/openrouter.py +63 -0
- zai/providers/qwen.py +47 -0
- zai/skills/__init__.py +0 -0
- zai/skills/registry.py +52 -0
- zai/tools/__init__.py +0 -0
- zai/tools/browser.py +224 -0
- zai/tools/code_runner.py +49 -0
- zai/tools/files.py +53 -0
- zai/tools/git.py +38 -0
- zai/tools/search.py +157 -0
- zai/tools/vision.py +128 -0
- zai/ui/__init__.py +0 -0
- zai/ui/input.py +199 -0
- zai_cli-0.1.0.dist-info/METADATA +722 -0
- zai_cli-0.1.0.dist-info/RECORD +68 -0
- zai_cli-0.1.0.dist-info/WHEEL +5 -0
- zai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- zai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- zai_cli-0.1.0.dist-info/top_level.txt +1 -0
zai/cli/interactive.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.prompt import Confirm, Prompt
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from ..config import get_api_key, get_models, load_config
|
|
11
|
+
from ..core.agent import plan_agent, run_agent, undo_last
|
|
12
|
+
from ..core.commands import get_command_prompt, list_commands, load_commands
|
|
13
|
+
from ..core.hooks import fire as fire_hook
|
|
14
|
+
from ..core.fallback import has_available_provider
|
|
15
|
+
from ..core.context import ContextManager
|
|
16
|
+
from ..core.memory import add_project, get_last_session, save_session
|
|
17
|
+
from ..core.session import (
|
|
18
|
+
delete_session,
|
|
19
|
+
list_sessions,
|
|
20
|
+
load_history,
|
|
21
|
+
load_latest_session,
|
|
22
|
+
rename_session,
|
|
23
|
+
save_auto_history,
|
|
24
|
+
save_history,
|
|
25
|
+
search_sessions,
|
|
26
|
+
)
|
|
27
|
+
from ..core.watch import is_watching, start_watch, stop_watch
|
|
28
|
+
from ..plugins import loader as plugin_loader
|
|
29
|
+
from ..providers.base import Message
|
|
30
|
+
from ..tools.git import get_diff
|
|
31
|
+
from ..ui.input import InteractiveInput
|
|
32
|
+
from .common import closest_command
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
CORE_COMMANDS = {
|
|
37
|
+
"help", "clear", "files", "diff", "undo", "plan", "test",
|
|
38
|
+
"watch", "commit", "review", "fix", "explain", "feature",
|
|
39
|
+
"session", "resume", "model", "memory", "commands",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _show_help() -> None:
|
|
44
|
+
console.print(
|
|
45
|
+
"[bold cyan]── Core ──[/bold cyan]\n"
|
|
46
|
+
"[cyan]/help[/cyan] This menu\n"
|
|
47
|
+
"[cyan]/clear[/cyan] Clear screen\n"
|
|
48
|
+
"[cyan]/files[/cyan] List files in current folder\n"
|
|
49
|
+
"[cyan]/diff[/cyan] Show git diff (colored)\n"
|
|
50
|
+
"[cyan]/undo[/cyan] Restore last AI-changed file\n"
|
|
51
|
+
"\n[bold cyan]── AI Modes ──[/bold cyan]\n"
|
|
52
|
+
"[cyan]/plan <task>[/cyan] Plan first, approve, then execute\n"
|
|
53
|
+
"[cyan]/test[/cyan] Run pytest + auto-fix failures\n"
|
|
54
|
+
"[cyan]/watch[/cyan] Toggle file-change watcher\n"
|
|
55
|
+
"\n[bold cyan]── Git & Code ──[/bold cyan]\n"
|
|
56
|
+
"[cyan]/commit[/cyan] Auto git commit with AI message\n"
|
|
57
|
+
"[cyan]/review[/cyan] Code review of current changes\n"
|
|
58
|
+
"[cyan]/fix <file>[/cyan] Fix bugs in a file\n"
|
|
59
|
+
"[cyan]/explain <file>[/cyan] Explain code in a file\n"
|
|
60
|
+
"[cyan]/feature <desc>[/cyan] Build a new feature\n"
|
|
61
|
+
"\n[bold cyan]── Session ──[/bold cyan]\n"
|
|
62
|
+
"[cyan]/resume [name][/cyan] Resume latest or named session\n"
|
|
63
|
+
"[cyan]/session save [name][/cyan] Save current conversation\n"
|
|
64
|
+
"[cyan]/session load <name>[/cyan] Load a saved conversation\n"
|
|
65
|
+
"[cyan]/session list[/cyan] List saved sessions\n"
|
|
66
|
+
"[cyan]/session search <query>[/cyan] Search saved conversations\n"
|
|
67
|
+
"[cyan]/session rename <old> <new>[/cyan] Rename a saved session\n"
|
|
68
|
+
"[cyan]/session delete <name>[/cyan] Delete a saved session\n"
|
|
69
|
+
"\n[bold cyan]── Settings ──[/bold cyan]\n"
|
|
70
|
+
"[cyan]/model list[/cyan] Show available AI models\n"
|
|
71
|
+
"[cyan]/model <name>[/cyan] Switch model (gemini/groq/etc)\n"
|
|
72
|
+
"[cyan]/memory[/cyan] Show last session info\n"
|
|
73
|
+
"[cyan]/commands[/cyan] List all slash commands\n"
|
|
74
|
+
"\n[dim]Just type naturally — zai creates files, reads code, "
|
|
75
|
+
"runs commands automatically.[/dim]"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _correct_slash_command(message: str) -> str:
|
|
80
|
+
stripped = message.strip()
|
|
81
|
+
if not stripped.startswith("/") or stripped.startswith("//"):
|
|
82
|
+
return message
|
|
83
|
+
parts = stripped[1:].split(" ", 1)
|
|
84
|
+
typed_name = parts[0].lower()
|
|
85
|
+
arguments = parts[1] if len(parts) > 1 else ""
|
|
86
|
+
available = (
|
|
87
|
+
CORE_COMMANDS
|
|
88
|
+
| set(load_commands().keys())
|
|
89
|
+
| set(plugin_loader.get_all_commands().keys())
|
|
90
|
+
)
|
|
91
|
+
if typed_name in available:
|
|
92
|
+
return message
|
|
93
|
+
corrected = closest_command(typed_name, available)
|
|
94
|
+
if not corrected:
|
|
95
|
+
return message
|
|
96
|
+
console.print(
|
|
97
|
+
f"[yellow]Command '/{typed_name}' not found; "
|
|
98
|
+
f"using '/{corrected}'.[/yellow]"
|
|
99
|
+
)
|
|
100
|
+
return f"/{corrected}{f' {arguments}' if arguments else ''}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _show_files(cwd: str) -> None:
|
|
104
|
+
try:
|
|
105
|
+
for name in sorted(os.listdir(cwd)):
|
|
106
|
+
path = os.path.join(cwd, name)
|
|
107
|
+
if os.path.isdir(path):
|
|
108
|
+
console.print(f" [blue]{name}/[/blue]")
|
|
109
|
+
else:
|
|
110
|
+
console.print(
|
|
111
|
+
f" [cyan]{name}[/cyan] [dim]{os.path.getsize(path)//1024}kb[/dim]"
|
|
112
|
+
)
|
|
113
|
+
except Exception as error:
|
|
114
|
+
console.print(f"[red]{error}[/red]")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _show_diff() -> None:
|
|
118
|
+
diff = get_diff()
|
|
119
|
+
if not diff:
|
|
120
|
+
console.print("[dim]No changes.[/dim]")
|
|
121
|
+
return
|
|
122
|
+
colored = []
|
|
123
|
+
for line in diff.splitlines():
|
|
124
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
125
|
+
colored.append(f"[green]{line}[/green]")
|
|
126
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
127
|
+
colored.append(f"[red]{line}[/red]")
|
|
128
|
+
elif line.startswith("@@"):
|
|
129
|
+
colored.append(f"[cyan]{line}[/cyan]")
|
|
130
|
+
else:
|
|
131
|
+
colored.append(f"[dim]{line}[/dim]")
|
|
132
|
+
console.print(Panel(
|
|
133
|
+
"\n".join(colored),
|
|
134
|
+
title="[cyan]Git Diff[/cyan]",
|
|
135
|
+
border_style="cyan",
|
|
136
|
+
))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _connect_mcp_servers() -> None:
|
|
140
|
+
try:
|
|
141
|
+
from ..mcp.client import connect
|
|
142
|
+
from ..mcp.manager import build_server_command, load_mcp
|
|
143
|
+
|
|
144
|
+
for server in load_mcp():
|
|
145
|
+
try:
|
|
146
|
+
command, env = build_server_command(server, os.getcwd())
|
|
147
|
+
except ValueError as error:
|
|
148
|
+
console.print(
|
|
149
|
+
f"[dim]MCP '{server.get('name', 'server')}': {error}[/dim]"
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
connect(server["name"], command, env)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def run_interactive(model: str = None) -> None:
|
|
158
|
+
cwd = os.getcwd()
|
|
159
|
+
if not has_available_provider():
|
|
160
|
+
console.print(Panel(
|
|
161
|
+
"[red]No AI provider available![/red]\n"
|
|
162
|
+
"Run [cyan]zai setup[/cyan] or start Ollama.",
|
|
163
|
+
border_style="red",
|
|
164
|
+
))
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
visible = [name for name in sorted(os.listdir(cwd)) if not name.startswith(".")]
|
|
169
|
+
except Exception:
|
|
170
|
+
visible = []
|
|
171
|
+
console.print(Panel(
|
|
172
|
+
f"[bold cyan]zai[/bold cyan] — AI coding assistant\n"
|
|
173
|
+
f"[dim]Folder: {cwd}[/dim]\n"
|
|
174
|
+
f"[dim]Files: {', '.join(visible[:12])}"
|
|
175
|
+
f"{'...' if len(visible) > 12 else ''}[/dim]\n"
|
|
176
|
+
"[dim]Ctrl+C to exit | /help for commands[/dim]",
|
|
177
|
+
border_style="cyan",
|
|
178
|
+
))
|
|
179
|
+
|
|
180
|
+
preferred = model or load_config()["default_model"]
|
|
181
|
+
add_project(Path(cwd).name, cwd)
|
|
182
|
+
fire_hook("SessionStart", {"cwd": cwd, "files": visible})
|
|
183
|
+
if (Path(cwd) / "CLAUDE.md").exists():
|
|
184
|
+
console.print(f"[dim]CLAUDE.md loaded from {cwd}[/dim]")
|
|
185
|
+
if (Path.home() / ".zai" / "CLAUDE.md").exists():
|
|
186
|
+
console.print("[dim]Global CLAUDE.md loaded (~/.zai/CLAUDE.md)[/dim]")
|
|
187
|
+
|
|
188
|
+
loaded_plugins = plugin_loader.load_all()
|
|
189
|
+
if loaded_plugins:
|
|
190
|
+
console.print(f"[dim]Plugins: {', '.join(loaded_plugins.keys())}[/dim]")
|
|
191
|
+
for filename, error in plugin_loader.get_errors().items():
|
|
192
|
+
console.print(f"[yellow]Plugin error ({filename}): {error}[/yellow]")
|
|
193
|
+
_connect_mcp_servers()
|
|
194
|
+
|
|
195
|
+
def available_commands():
|
|
196
|
+
return (
|
|
197
|
+
CORE_COMMANDS
|
|
198
|
+
| set(load_commands().keys())
|
|
199
|
+
| set(plugin_loader.get_all_commands().keys())
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def saved_session_names():
|
|
203
|
+
return [session["name"] for session in list_sessions()]
|
|
204
|
+
|
|
205
|
+
terminal_input = InteractiveInput(
|
|
206
|
+
cwd,
|
|
207
|
+
command_provider=available_commands,
|
|
208
|
+
model_provider=lambda: get_models().keys(),
|
|
209
|
+
session_provider=saved_session_names,
|
|
210
|
+
)
|
|
211
|
+
if terminal_input.enhanced:
|
|
212
|
+
console.print(
|
|
213
|
+
"[dim]Input history and completion enabled. "
|
|
214
|
+
"Alt+Enter or Ctrl+J inserts a newline.[/dim]"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
session_context = ContextManager(model=preferred)
|
|
218
|
+
initial_history = [
|
|
219
|
+
Message(
|
|
220
|
+
role="user",
|
|
221
|
+
content=(
|
|
222
|
+
f"My current folder is: {cwd}\nFiles here: {', '.join(visible)}\n"
|
|
223
|
+
"Help me work on this project."
|
|
224
|
+
),
|
|
225
|
+
pinned=True,
|
|
226
|
+
),
|
|
227
|
+
Message(
|
|
228
|
+
role="assistant",
|
|
229
|
+
content=(
|
|
230
|
+
f"I can see your folder at {cwd} with these files: "
|
|
231
|
+
f"{', '.join(visible)}. I can read, create, and edit files directly. "
|
|
232
|
+
"What would you like to do?"
|
|
233
|
+
),
|
|
234
|
+
pinned=True,
|
|
235
|
+
),
|
|
236
|
+
]
|
|
237
|
+
session_context.replace_messages(initial_history)
|
|
238
|
+
history = session_context.get_messages()
|
|
239
|
+
|
|
240
|
+
def remember_turn(user_content: str, assistant_content: str) -> None:
|
|
241
|
+
session_context.add("user", user_content)
|
|
242
|
+
session_context.add("assistant", assistant_content)
|
|
243
|
+
if session_context.is_near_limit():
|
|
244
|
+
session_context.compress(preferred)
|
|
245
|
+
save_auto_history(history, cwd)
|
|
246
|
+
|
|
247
|
+
while True:
|
|
248
|
+
try:
|
|
249
|
+
try:
|
|
250
|
+
message = terminal_input.prompt()
|
|
251
|
+
except KeyboardInterrupt:
|
|
252
|
+
console.print("[dim]Input cancelled.[/dim]")
|
|
253
|
+
continue
|
|
254
|
+
if not message.strip():
|
|
255
|
+
continue
|
|
256
|
+
message = _correct_slash_command(message)
|
|
257
|
+
stripped = message.strip()
|
|
258
|
+
|
|
259
|
+
if stripped == "/help":
|
|
260
|
+
_show_help()
|
|
261
|
+
elif stripped == "/clear":
|
|
262
|
+
console.clear()
|
|
263
|
+
elif stripped == "/files":
|
|
264
|
+
_show_files(cwd)
|
|
265
|
+
elif stripped.startswith("/model "):
|
|
266
|
+
name = stripped.split(" ", 1)[1].strip()
|
|
267
|
+
if name == "list":
|
|
268
|
+
for key, data in get_models().items():
|
|
269
|
+
key_status = (
|
|
270
|
+
"[green]ok[/green]"
|
|
271
|
+
if get_api_key(data["provider"])
|
|
272
|
+
else "[red]no key[/red]"
|
|
273
|
+
)
|
|
274
|
+
console.print(
|
|
275
|
+
f" [cyan]{key}[/cyan] — {data['name']} ({key_status})"
|
|
276
|
+
)
|
|
277
|
+
elif name in get_models():
|
|
278
|
+
preferred = name
|
|
279
|
+
session_context.set_model(name)
|
|
280
|
+
console.print(f"[green]Switched to {name}[/green]")
|
|
281
|
+
else:
|
|
282
|
+
console.print(f"[red]Unknown model: {name}[/red]")
|
|
283
|
+
elif stripped == "/memory":
|
|
284
|
+
last = get_last_session()
|
|
285
|
+
if last:
|
|
286
|
+
console.print(f"[dim]{last['task']} — {last['date']}[/dim]")
|
|
287
|
+
elif stripped == "/undo":
|
|
288
|
+
console.print(f"[cyan]{undo_last(cwd)}[/cyan]")
|
|
289
|
+
elif stripped == "/diff":
|
|
290
|
+
_show_diff()
|
|
291
|
+
elif stripped.startswith("/plan"):
|
|
292
|
+
task = stripped[5:].strip()
|
|
293
|
+
if not task:
|
|
294
|
+
console.print("[red]Usage: /plan <what you want to build>[/red]")
|
|
295
|
+
else:
|
|
296
|
+
console.print("[dim]Planning...[/dim]")
|
|
297
|
+
plan = plan_agent(task, history, cwd, preferred)
|
|
298
|
+
if plan and Prompt.ask("[cyan]Proceed?[/cyan]", default="no").lower() in ("yes", "y"):
|
|
299
|
+
console.print("[dim]Executing...[/dim]")
|
|
300
|
+
result = run_agent(task, history, cwd, preferred)
|
|
301
|
+
if result:
|
|
302
|
+
remember_turn(task, result)
|
|
303
|
+
save_session(task=task[:80], model=preferred)
|
|
304
|
+
elif stripped == "/test":
|
|
305
|
+
for round_number in range(3):
|
|
306
|
+
console.print(
|
|
307
|
+
f"[cyan]Running tests (round {round_number + 1}/3)...[/cyan]"
|
|
308
|
+
)
|
|
309
|
+
result = subprocess.run(
|
|
310
|
+
["python", "-m", "pytest", "--tb=short", "-q"],
|
|
311
|
+
capture_output=True,
|
|
312
|
+
text=True,
|
|
313
|
+
cwd=cwd,
|
|
314
|
+
timeout=120,
|
|
315
|
+
)
|
|
316
|
+
output = (result.stdout + result.stderr).strip()
|
|
317
|
+
if not output:
|
|
318
|
+
console.print("[yellow]No tests found.[/yellow]")
|
|
319
|
+
break
|
|
320
|
+
console.print(Panel(
|
|
321
|
+
output[:2000],
|
|
322
|
+
title="[cyan]Test Output[/cyan]",
|
|
323
|
+
border_style="cyan",
|
|
324
|
+
))
|
|
325
|
+
if result.returncode == 0:
|
|
326
|
+
console.print("[green]All tests passing![/green]")
|
|
327
|
+
break
|
|
328
|
+
if round_number >= 2:
|
|
329
|
+
console.print("[yellow]Max rounds reached. Tests still failing.[/yellow]")
|
|
330
|
+
break
|
|
331
|
+
console.print("[yellow]Asking AI to fix...[/yellow]")
|
|
332
|
+
fix_prompt = (
|
|
333
|
+
f"Tests are failing:\n\n{output}\n\n"
|
|
334
|
+
"Read the failing files and fix the issues."
|
|
335
|
+
)
|
|
336
|
+
fix_result = run_agent(fix_prompt, history, cwd, preferred)
|
|
337
|
+
if fix_result:
|
|
338
|
+
remember_turn(fix_prompt, fix_result)
|
|
339
|
+
elif stripped.startswith("/resume"):
|
|
340
|
+
parts = stripped.split(None, 1)
|
|
341
|
+
name = parts[1].strip() if len(parts) > 1 else None
|
|
342
|
+
latest = None if name else load_latest_session(cwd)
|
|
343
|
+
loaded = load_history(name) if name else (
|
|
344
|
+
latest[0] if latest else None
|
|
345
|
+
)
|
|
346
|
+
resumed_name = name or (
|
|
347
|
+
latest[1]["name"] if latest else "latest"
|
|
348
|
+
)
|
|
349
|
+
if loaded:
|
|
350
|
+
session_context.replace_messages(loaded)
|
|
351
|
+
console.print(
|
|
352
|
+
f"[green]Session resumed ({resumed_name}):[/green] "
|
|
353
|
+
f"{len(loaded)} messages"
|
|
354
|
+
)
|
|
355
|
+
elif name:
|
|
356
|
+
console.print(f"[red]Session not found: {name}[/red]")
|
|
357
|
+
else:
|
|
358
|
+
console.print(
|
|
359
|
+
"[yellow]No saved sessions. "
|
|
360
|
+
"Use /session save [name] first.[/yellow]"
|
|
361
|
+
)
|
|
362
|
+
elif stripped.startswith("/session"):
|
|
363
|
+
parts = stripped.split()
|
|
364
|
+
action = parts[1] if len(parts) > 1 else "list"
|
|
365
|
+
arguments = parts[2:]
|
|
366
|
+
argument = " ".join(arguments) if arguments else None
|
|
367
|
+
if action == "save":
|
|
368
|
+
path = save_history(history, argument, project_path=cwd)
|
|
369
|
+
console.print(f"[green]Session saved:[/green] {path}")
|
|
370
|
+
elif action == "load":
|
|
371
|
+
if not argument:
|
|
372
|
+
console.print("[red]Usage: /session load <name>[/red]")
|
|
373
|
+
else:
|
|
374
|
+
loaded = load_history(argument)
|
|
375
|
+
if loaded:
|
|
376
|
+
session_context.replace_messages(loaded)
|
|
377
|
+
console.print(
|
|
378
|
+
f"[green]Session loaded:[/green] {len(loaded)} messages"
|
|
379
|
+
)
|
|
380
|
+
else:
|
|
381
|
+
console.print(f"[red]Session not found: {argument}[/red]")
|
|
382
|
+
elif action == "search":
|
|
383
|
+
if not argument:
|
|
384
|
+
console.print("[red]Usage: /session search <query>[/red]")
|
|
385
|
+
else:
|
|
386
|
+
matches = search_sessions(argument, project_path=cwd)
|
|
387
|
+
if not matches:
|
|
388
|
+
console.print("[dim]No matching sessions.[/dim]")
|
|
389
|
+
else:
|
|
390
|
+
table = Table(
|
|
391
|
+
title="Session Search",
|
|
392
|
+
border_style="cyan",
|
|
393
|
+
)
|
|
394
|
+
table.add_column("Name", style="cyan")
|
|
395
|
+
table.add_column("Title")
|
|
396
|
+
table.add_column("Messages", justify="right")
|
|
397
|
+
for session in matches:
|
|
398
|
+
table.add_row(
|
|
399
|
+
session["name"],
|
|
400
|
+
session["title"],
|
|
401
|
+
str(session["messages"]),
|
|
402
|
+
)
|
|
403
|
+
console.print(table)
|
|
404
|
+
elif action == "rename":
|
|
405
|
+
if len(arguments) != 2:
|
|
406
|
+
console.print(
|
|
407
|
+
"[red]Usage: /session rename <old> <new>[/red]"
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
renamed = rename_session(arguments[0], arguments[1])
|
|
411
|
+
if renamed:
|
|
412
|
+
console.print(
|
|
413
|
+
f"[green]Session renamed:[/green] {arguments[1]}"
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
console.print(
|
|
417
|
+
"[red]Rename failed: session missing, ambiguous, "
|
|
418
|
+
"automatic, or destination exists.[/red]"
|
|
419
|
+
)
|
|
420
|
+
elif action == "delete":
|
|
421
|
+
if len(arguments) != 1:
|
|
422
|
+
console.print("[red]Usage: /session delete <name>[/red]")
|
|
423
|
+
elif Confirm.ask(
|
|
424
|
+
f"Delete session '{arguments[0]}'?",
|
|
425
|
+
default=False,
|
|
426
|
+
) and delete_session(arguments[0]):
|
|
427
|
+
console.print(
|
|
428
|
+
f"[green]Session deleted:[/green] {arguments[0]}"
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
console.print("[yellow]Session was not deleted.[/yellow]")
|
|
432
|
+
elif action == "list":
|
|
433
|
+
sessions = list_sessions(project_path=cwd)
|
|
434
|
+
if not sessions:
|
|
435
|
+
console.print("[dim]No saved sessions.[/dim]")
|
|
436
|
+
else:
|
|
437
|
+
table = Table(title="Saved Sessions", border_style="cyan")
|
|
438
|
+
table.add_column("ID", style="dim")
|
|
439
|
+
table.add_column("Name", style="cyan")
|
|
440
|
+
table.add_column("Title")
|
|
441
|
+
table.add_column("Messages", justify="right")
|
|
442
|
+
for session in sessions:
|
|
443
|
+
table.add_row(
|
|
444
|
+
session["id"][:8],
|
|
445
|
+
session["name"],
|
|
446
|
+
session["title"],
|
|
447
|
+
str(session["messages"]),
|
|
448
|
+
)
|
|
449
|
+
console.print(table)
|
|
450
|
+
else:
|
|
451
|
+
console.print(
|
|
452
|
+
"[red]Usage: /session list | save [name] | load <name> | "
|
|
453
|
+
"search <query> | rename <old> <new> | delete <name>[/red]"
|
|
454
|
+
)
|
|
455
|
+
elif stripped == "/watch":
|
|
456
|
+
if is_watching():
|
|
457
|
+
stop_watch()
|
|
458
|
+
console.print("[yellow]Watch mode off.[/yellow]")
|
|
459
|
+
else:
|
|
460
|
+
def on_file_change(filename):
|
|
461
|
+
prompt = (
|
|
462
|
+
f"File changed: {filename}. "
|
|
463
|
+
"Read it and check for issues or errors."
|
|
464
|
+
)
|
|
465
|
+
result = run_agent(prompt, history, cwd, preferred)
|
|
466
|
+
if result:
|
|
467
|
+
remember_turn(prompt, result)
|
|
468
|
+
|
|
469
|
+
if start_watch(cwd, on_file_change):
|
|
470
|
+
console.print(f"[green]Watch mode on.[/green] Watching: {cwd}")
|
|
471
|
+
elif stripped.startswith("/") and not stripped.startswith("//"):
|
|
472
|
+
parts = stripped[1:].split(" ", 1)
|
|
473
|
+
command_name = parts[0].lower()
|
|
474
|
+
arguments = parts[1] if len(parts) > 1 else ""
|
|
475
|
+
prompt = get_command_prompt(command_name, arguments)
|
|
476
|
+
if not prompt:
|
|
477
|
+
plugin_commands = plugin_loader.get_all_commands()
|
|
478
|
+
if command_name in plugin_commands:
|
|
479
|
+
prompt = plugin_commands[command_name].get("body", "").replace(
|
|
480
|
+
"$ARGUMENTS",
|
|
481
|
+
arguments,
|
|
482
|
+
)
|
|
483
|
+
if prompt:
|
|
484
|
+
console.print(f"[dim]Running /{command_name}...[/dim]")
|
|
485
|
+
result = run_agent(prompt, history, cwd, preferred)
|
|
486
|
+
if result:
|
|
487
|
+
remember_turn(
|
|
488
|
+
f"/{command_name} {arguments}".strip(),
|
|
489
|
+
result,
|
|
490
|
+
)
|
|
491
|
+
save_session(task=f"/{command_name}", model=preferred)
|
|
492
|
+
elif command_name == "commands":
|
|
493
|
+
for command in list_commands():
|
|
494
|
+
console.print(
|
|
495
|
+
f" [cyan]/{command['name']}[/cyan] — "
|
|
496
|
+
f"{command['description']}"
|
|
497
|
+
)
|
|
498
|
+
for name, data in plugin_loader.get_all_commands().items():
|
|
499
|
+
console.print(
|
|
500
|
+
f" [blue]/{name}[/blue] — "
|
|
501
|
+
f"{data.get('description', '')} [dim](plugin)[/dim]"
|
|
502
|
+
)
|
|
503
|
+
else:
|
|
504
|
+
console.print(f"[red]Unknown command: /{command_name}[/red]")
|
|
505
|
+
console.print("[dim]Type /commands to see available commands[/dim]")
|
|
506
|
+
else:
|
|
507
|
+
fire_hook("UserPromptSubmit", {"message": stripped[:200]})
|
|
508
|
+
result = run_agent(stripped, history, cwd, preferred)
|
|
509
|
+
if result:
|
|
510
|
+
remember_turn(stripped, result)
|
|
511
|
+
save_session(task=stripped[:80], model=preferred)
|
|
512
|
+
except KeyboardInterrupt:
|
|
513
|
+
console.print("\n[yellow]Current operation cancelled.[/yellow]")
|
|
514
|
+
continue
|
|
515
|
+
except EOFError:
|
|
516
|
+
fire_hook("SessionEnd", {"cwd": cwd, "messages": len(history)})
|
|
517
|
+
console.print("\n[dim]Goodbye![/dim]")
|
|
518
|
+
break
|