Skip to content

Commit 11cf217

Browse files
committed
feat: readline arrow keys, interactive model selection, /set command, /clear, tab completion
1 parent 6b5d60f commit 11cf217

2 files changed

Lines changed: 194 additions & 41 deletions

File tree

deepworm/__main__.py

Lines changed: 164 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -941,32 +941,53 @@ def main(args: list[str] | None = None) -> None:
941941

942942

943943
def _interactive_shell(opts: argparse.Namespace, config: "Config", cache) -> None:
944-
"""Interactive CLI shell — Claude Code style experience."""
944+
"""Interactive CLI shell — Claude Code style experience with readline support."""
945945
import time as _time
946+
import readline as _readline
947+
948+
# ---- Readline setup for arrow-key history & tab completion ----
949+
_HISTORY_FILE = os.path.expanduser("~/.deepworm_history")
950+
_COMMANDS = [
951+
"/help", "/compare", "/polish", "/graph", "/config",
952+
"/models", "/history", "/set", "/clear", "/exit",
953+
]
954+
955+
def _completer(text: str, state: int) -> str | None:
956+
if text.startswith("/"):
957+
matches = [c for c in _COMMANDS if c.startswith(text)]
958+
else:
959+
matches = []
960+
return matches[state] if state < len(matches) else None
961+
962+
_readline.set_completer(_completer)
963+
_readline.parse_and_bind("tab: complete")
964+
_readline.set_completer_delims(" \t\n")
965+
_readline.set_history_length(500)
966+
try:
967+
_readline.read_history_file(_HISTORY_FILE)
968+
except (FileNotFoundError, OSError):
969+
pass
970+
971+
def _save_history() -> None:
972+
try:
973+
_readline.write_history_file(_HISTORY_FILE)
974+
except OSError:
975+
pass
946976

947977
# Detect provider info
948978
provider_info = f"{config.provider}/{config.model}"
949979

950980
# Welcome banner
951981
console.print()
952982
console.print(Panel(
953-
f"[bold]deepworm[/bold] v{__version__}\n\n"
983+
f"[bold white]deepworm[/bold white] v{__version__}\n\n"
954984
f"AI-powered deep research agent\n"
955985
f"Provider: [cyan]{provider_info}[/cyan]",
956-
border_style="blue",
986+
border_style="bright_blue",
957987
expand=False,
958988
))
959989
console.print()
960-
console.print(" [dim]Type a topic to research, or use a command:[/dim]")
961-
console.print()
962-
console.print(" [cyan]/help[/cyan] Show all commands")
963-
console.print(" [cyan]/compare[/cyan] Compare multiple topics")
964-
console.print(" [cyan]/polish[/cyan] Analyze a report file")
965-
console.print(" [cyan]/graph[/cyan] Extract knowledge graph")
966-
console.print(" [cyan]/config[/cyan] Show current configuration")
967-
console.print(" [cyan]/models[/cyan] List available models")
968-
console.print(" [cyan]/history[/cyan] Show research history")
969-
console.print(" [cyan]/exit[/cyan] Exit deepworm")
990+
console.print(" [dim]Just type a topic to start researching. Use[/dim] [cyan]/help[/cyan] [dim]for commands.[/dim]")
970991
console.print()
971992

972993
total_tokens = 0
@@ -975,23 +996,27 @@ def _interactive_shell(opts: argparse.Namespace, config: "Config", cache) -> Non
975996

976997
while True:
977998
try:
978-
# Prompt with token count if any work was done
999+
# Build prompt — plain text for readline (no Rich markup)
9791000
if total_tokens > 0:
9801001
elapsed = _time.time() - session_start
981-
prompt_text = f"[bold blue]deepworm[/bold blue] [dim]({total_tokens:,} tokens | {elapsed:.0f}s)[/dim] > "
1002+
prompt_str = f"\033[1;34mdeepworm\033[0m \033[2m({total_tokens:,} tokens | {elapsed:.0f}s)\033[0m > "
9821003
else:
983-
prompt_text = "[bold blue]deepworm[/bold blue] > "
1004+
prompt_str = "\033[1;34mdeepworm\033[0m > "
9841005

985-
user_input = console.input(prompt_text).strip()
1006+
user_input = input(prompt_str).strip()
9861007
except (KeyboardInterrupt, EOFError):
1008+
_save_history()
9871009
console.print("\n[dim]Goodbye![/dim]")
9881010
break
9891011

9901012
if not user_input:
9911013
continue
9921014

9931015
# --- Commands ---
994-
if user_input.lower() in ("/exit", "/quit", "/q", "exit", "quit"):
1016+
cmd_lower = user_input.lower()
1017+
1018+
if cmd_lower in ("/exit", "/quit", "/q", "exit", "quit"):
1019+
_save_history()
9951020
elapsed = _time.time() - session_start
9961021
console.print()
9971022
if researches_done > 0:
@@ -1005,23 +1030,31 @@ def _interactive_shell(opts: argparse.Namespace, config: "Config", cache) -> Non
10051030
console.print("[dim]Goodbye![/dim]")
10061031
break
10071032

1008-
if user_input.lower() in ("/help", "/h", "help"):
1033+
if cmd_lower in ("/help", "/h", "help"):
10091034
_show_help()
10101035
continue
10111036

1012-
if user_input.lower() in ("/config", "/cfg"):
1037+
if cmd_lower in ("/config", "/cfg"):
10131038
_show_config(config)
10141039
continue
10151040

1016-
if user_input.lower() in ("/history", "/hist"):
1041+
if cmd_lower in ("/history", "/hist"):
10171042
_show_history_interactive()
10181043
continue
10191044

1020-
if user_input.lower() in ("/models",):
1021-
_show_models(config)
1045+
if cmd_lower in ("/models",):
1046+
_show_models_interactive(config)
1047+
continue
1048+
1049+
if cmd_lower.startswith("/set "):
1050+
_handle_set_command(user_input, config)
1051+
continue
1052+
1053+
if cmd_lower in ("/clear",):
1054+
os.system("cls" if os.name == "nt" else "clear")
10221055
continue
10231056

1024-
if user_input.lower().startswith("/compare"):
1057+
if cmd_lower.startswith("/compare"):
10251058
parts = user_input.split(maxsplit=1)
10261059
if len(parts) < 2:
10271060
console.print("[dim]Usage: /compare topic1, topic2, topic3[/dim]")
@@ -1043,7 +1076,7 @@ def _interactive_shell(opts: argparse.Namespace, config: "Config", cache) -> Non
10431076
console.print(f"[red]Error: {e}[/red]")
10441077
continue
10451078

1046-
if user_input.lower().startswith("/polish"):
1079+
if cmd_lower.startswith("/polish"):
10471080
parts = user_input.split(maxsplit=1)
10481081
if len(parts) < 2:
10491082
console.print("[dim]Usage: /polish <file.md>[/dim]")
@@ -1057,7 +1090,7 @@ def _interactive_shell(opts: argparse.Namespace, config: "Config", cache) -> Non
10571090
_run_polish_inline(text)
10581091
continue
10591092

1060-
if user_input.lower().startswith("/graph"):
1093+
if cmd_lower.startswith("/graph"):
10611094
parts = user_input.split(maxsplit=1)
10621095
if len(parts) < 2:
10631096
console.print("[dim]Usage: /graph <file.md>[/dim]")
@@ -1187,15 +1220,17 @@ def _show_help() -> None:
11871220
console.print()
11881221

11891222
cmds = Table(show_header=False, box=None, padding=(0, 2))
1190-
cmds.add_column(style="cyan", min_width=20)
1223+
cmds.add_column(style="cyan", min_width=24)
11911224
cmds.add_column(style="dim")
11921225
cmds.add_row("/help", "Show this help")
1226+
cmds.add_row("/models", "List & switch models interactively")
1227+
cmds.add_row("/set <key> <value>", "Change config (provider, model, depth, breadth)")
1228+
cmds.add_row("/config", "Show current configuration")
11931229
cmds.add_row("/compare t1, t2, t3", "Compare multiple topics")
11941230
cmds.add_row("/polish file.md", "Run polish pipeline on a file")
11951231
cmds.add_row("/graph file.md", "Extract knowledge graph from a file")
1196-
cmds.add_row("/config", "Show current configuration")
1197-
cmds.add_row("/models", "List available models")
11981232
cmds.add_row("/history", "Show research history")
1233+
cmds.add_row("/clear", "Clear screen")
11991234
cmds.add_row("/exit", "Exit deepworm")
12001235
console.print(cmds)
12011236

@@ -1204,6 +1239,12 @@ def _show_help() -> None:
12041239
console.print(" [dim]AI safety --output report.md --polish[/dim]")
12051240
console.print()
12061241

1242+
console.print(" [bold]Quick config:[/bold]")
1243+
console.print(" [dim]/set provider google[/dim]")
1244+
console.print(" [dim]/set model gemini-2.5-flash[/dim]")
1245+
console.print(" [dim]/set depth 3[/dim]")
1246+
console.print()
1247+
12071248
console.print(" [bold]Examples:[/bold]")
12081249
console.print(" [dim]> What is WebAssembly and why does it matter?[/dim]")
12091250
console.print(" [dim]> /compare React, Vue, Svelte[/dim]")
@@ -1254,27 +1295,112 @@ def _show_history_interactive() -> None:
12541295

12551296

12561297
def _show_models(config: "Config") -> None:
1257-
"""Show available models for current provider."""
1298+
"""Show available models for current provider (non-interactive fallback)."""
1299+
_show_models_interactive(config)
1300+
1301+
1302+
def _show_models_interactive(config: "Config") -> None:
1303+
"""Show available models with numbered selection."""
12581304
console.print()
1259-
models = {
1305+
all_models = {
12601306
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"],
12611307
"anthropic": ["claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022", "claude-3-haiku-20240307"],
12621308
"google": ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-3-flash-preview"],
1263-
"ollama": ["llama3.1", "mistral", "codellama", "phi3"],
1309+
"ollama": ["llama3.2", "llama3.1", "mistral", "codellama", "phi3", "qwen2.5"],
12641310
}
1311+
1312+
# Build flat numbered list
1313+
entries: list[tuple[str, str]] = []
1314+
for provider, models in all_models.items():
1315+
for model in models:
1316+
entries.append((provider, model))
1317+
12651318
tbl = Table(title="Available Models", header_style="bold", padding=(0, 1))
1266-
tbl.add_column("Provider", style="cyan")
1267-
tbl.add_column("Models")
1319+
tbl.add_column("#", style="bold cyan", justify="right", width=3)
1320+
tbl.add_column("Provider", style="dim")
1321+
tbl.add_column("Model")
12681322
tbl.add_column("", style="dim")
1269-
for provider, model_list in models.items():
1270-
is_current = provider == config.provider
1271-
marker = "[green]active[/green]" if is_current else ""
1272-
tbl.add_row(provider, ", ".join(model_list), marker)
1323+
1324+
for idx, (provider, model) in enumerate(entries, 1):
1325+
is_current = provider == config.provider and model == config.model
1326+
marker = "[green bold]\u2713 active[/green bold]" if is_current else ""
1327+
model_style = "[bold green]" if is_current else ""
1328+
model_end = "[/bold green]" if is_current else ""
1329+
tbl.add_row(str(idx), provider, f"{model_style}{model}{model_end}", marker)
1330+
12731331
console.print(tbl)
12741332
console.print(f"\n [dim]Current: {config.provider}/{config.model}[/dim]")
1333+
console.print(" [dim]Type a number to switch, or press Enter to cancel:[/dim]")
1334+
1335+
try:
1336+
choice = input(" > ").strip()
1337+
except (KeyboardInterrupt, EOFError):
1338+
console.print()
1339+
return
1340+
1341+
if not choice:
1342+
return
1343+
1344+
try:
1345+
idx = int(choice)
1346+
if 1 <= idx <= len(entries):
1347+
new_provider, new_model = entries[idx - 1]
1348+
config.provider = new_provider
1349+
config.model = new_model
1350+
console.print(f" [green]\u2713 Switched to {new_provider}/{new_model}[/green]")
1351+
else:
1352+
console.print(f" [yellow]Invalid number. Choose 1-{len(entries)}.[/yellow]")
1353+
except ValueError:
1354+
# Maybe they typed a model name directly
1355+
for provider, model in entries:
1356+
if model == choice or choice == f"{provider}/{model}":
1357+
config.provider = provider
1358+
config.model = model
1359+
console.print(f" [green]\u2713 Switched to {provider}/{model}[/green]")
1360+
break
1361+
else:
1362+
console.print(f" [yellow]Unknown model: {choice}[/yellow]")
12751363
console.print()
12761364

12771365

1366+
def _handle_set_command(user_input: str, config: "Config") -> None:
1367+
"""Handle /set key value commands."""
1368+
parts = user_input.split(maxsplit=2)
1369+
if len(parts) < 3:
1370+
console.print(" [dim]Usage: /set <key> <value>[/dim]")
1371+
console.print(" [dim]Keys: provider, model, depth, breadth[/dim]")
1372+
return
1373+
1374+
key = parts[1].lower()
1375+
value = parts[2].strip()
1376+
1377+
if key == "provider":
1378+
valid = ("openai", "anthropic", "google", "ollama")
1379+
if value not in valid:
1380+
console.print(f" [yellow]Valid providers: {', '.join(valid)}[/yellow]")
1381+
return
1382+
config.provider = value
1383+
console.print(f" [green]\u2713 provider = {value}[/green]")
1384+
elif key == "model":
1385+
config.model = value
1386+
console.print(f" [green]\u2713 model = {value}[/green]")
1387+
elif key == "depth":
1388+
try:
1389+
config.depth = int(value)
1390+
console.print(f" [green]\u2713 depth = {value}[/green]")
1391+
except ValueError:
1392+
console.print(" [yellow]depth must be a number.[/yellow]")
1393+
elif key == "breadth":
1394+
try:
1395+
config.breadth = int(value)
1396+
console.print(f" [green]\u2713 breadth = {value}[/green]")
1397+
except ValueError:
1398+
console.print(" [yellow]breadth must be a number.[/yellow]")
1399+
else:
1400+
console.print(f" [yellow]Unknown key: {key}[/yellow]")
1401+
console.print(" [dim]Valid keys: provider, model, depth, breadth[/dim]")
1402+
1403+
12781404
def _run_polish_inline(text: str, topic: str = "") -> None:
12791405
"""Run polish pipeline inline in interactive mode."""
12801406
from .readability import analyze_readability

tests/test_cli.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,38 @@ def test_interactive_shell_config_function():
194194

195195

196196
def test_interactive_shell_models_function():
197-
"""Test that _show_models runs without error."""
198-
from deepworm.__main__ import _show_models
197+
"""Test that _show_models_interactive runs without error."""
198+
from unittest.mock import patch
199+
from deepworm.__main__ import _show_models_interactive
199200
from deepworm.config import Config
200201
config = Config.auto()
201-
_show_models(config) # Should not raise
202+
with patch("builtins.input", return_value=""):
203+
_show_models_interactive(config) # Should not raise
204+
205+
206+
def test_interactive_models_switch():
207+
"""Test model switching by number."""
208+
from unittest.mock import patch
209+
from deepworm.__main__ import _show_models_interactive
210+
from deepworm.config import Config
211+
config = Config.auto()
212+
with patch("builtins.input", return_value="1"):
213+
_show_models_interactive(config)
214+
# Model should have switched to the first entry
215+
assert config.model in ("gpt-4o", config.model)
216+
217+
218+
def test_handle_set_command():
219+
"""Test /set command for changing config."""
220+
from deepworm.__main__ import _handle_set_command
221+
from deepworm.config import Config
222+
config = Config.auto()
223+
_handle_set_command("/set depth 5", config)
224+
assert config.depth == 5
225+
_handle_set_command("/set breadth 8", config)
226+
assert config.breadth == 8
227+
_handle_set_command("/set model test-model", config)
228+
assert config.model == "test-model"
202229

203230

204231
def test_interactive_polish_inline():

0 commit comments

Comments
 (0)