Skip to content

Commit 86201a5

Browse files
NikitasT2003claude
andcommitted
feat(chat): Claude-Code-style TUI for thesis chat
deepagents ships a separate `deepagents-cli` package with a TUI — streaming, tool-call visibility, slash commands, HITL — but it wraps a different agent definition and can't host ours. User asked for the same UX on top of our agent. Rewrote `thesis chat` end-to-end: Streaming + inline tool rendering - Replaced `agent.invoke()` with `agent.stream(stream_mode="values")`. - Each yielded state delta is diffed against the previous seen count and new messages are rendered as they arrive. - `AIMessage` with `tool_calls` → one line per call showing `→ tool_name(arg_preview)` where the preview prefers file_path / path / pattern / old_string, truncated at 60 chars. - `ToolMessage` → indented `↳ <result preview>` (newlines collapsed to `⏎`, truncated at 120 chars). - Final `AIMessage` content → `rich.markdown.Markdown` rendered inside a green Panel titled "agent". Slash commands (all new) /help reprint the banner / cheat sheet /new rotate to a fresh `t-<timestamp>` thread /thread [id] show current or switch to a specific thread /status thread + raw/wiki/chapter counts (reads disk, no LLM) /model table of drafter / curator / researcher active models /clear clear the terminal (history is preserved in sqlite) /history [N] acknowledge + point at `data/checkpoints.db` /quit /exit leave Multi-line input - Trailing `\` continues on the next line (prompt changes to `…`) - Opening triple-backticks ``` enters a fenced paste mode; the block closes on a standalone ``` line Control flow - `KeyboardInterrupt` during generation → "turn cancelled" + loop continues. Second Ctrl-C at the prompt exits cleanly. - `--quiet` flag suppresses tool-call traces; only the final assistant reply is shown in its markdown panel. Helpers extracted + unit-tested _preview_tool_args(name, args) - picks the most informative key and truncates; never raises on empty args _preview_tool_result(content) - collapses newlines, truncates, safe on non-string content _read_multiline(prompt) - backslash + fenced block handling _handle_slash(cmd, p, tid) - returns (new_thread_id, keep_running) _render_new_messages(state, seen, current_tools) - diff-render helper; returns the new seen count Tests tests/test_chat_tui.py - 18 new tests covering banner, each slash command, multi-line variants, streaming tool-call rendering, --quiet mode, and the four preview helpers. tests/test_commands.py - updated `_StubAgent` + the inline Flaky class to implement `.stream()` as well as `.invoke()` so the existing chat tests keep working. Smoke-tested manually: banner, /help, /status, /model, /quit render correctly under Windows PowerShell with UTF-8 glyphs. Totals - 330 passed, 1 skipped (symlink sandbox test on Windows) - Ruff clean - No new external dependencies (rich + typer were already in) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 891abd8 commit 86201a5

5 files changed

Lines changed: 589 additions & 41 deletions

File tree

src/thesis_agent/cli.py

Lines changed: 252 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
pass
3535

3636
from thesis_agent import __version__ # noqa: E402
37-
from thesis_agent.config import paths, read_thread_id, write_thread_id # noqa: E402
37+
from thesis_agent.config import Paths, paths, read_thread_id, write_thread_id # noqa: E402
3838

3939
app = typer.Typer(
4040
name="thesis",
@@ -991,64 +991,284 @@ def lint(
991991
)
992992

993993

994+
_CHAT_BANNER = """[bold]thesis-agent chat[/bold]
995+
996+
Slash commands:
997+
[cyan]/help[/] show this help
998+
[cyan]/new[/] start a fresh thread
999+
[cyan]/thread[/] <id> switch to a specific thread
1000+
[cyan]/status[/] workspace counts + current model
1001+
[cyan]/history[/] [N] show last N messages in this thread (default 10)
1002+
[cyan]/model[/] show the active models for each role
1003+
[cyan]/clear[/] clear the screen (history preserved)
1004+
[cyan]/quit[/] /exit leave
1005+
1006+
Input: end a line with [cyan]\\\\[/] to continue on the next line,
1007+
or open a fenced block with [cyan]```[/] for multi-line paste.
1008+
Ctrl-C during generation cancels the current turn; twice exits."""
1009+
1010+
1011+
def _render_new_messages(
1012+
state: dict,
1013+
seen: int,
1014+
current_tool_calls: dict,
1015+
) -> int:
1016+
"""Print every message in state past `seen`. Returns the new count.
1017+
1018+
`current_tool_calls` maps tool_call_id → short preview, so when a
1019+
ToolMessage comes back we can show it inline under its caller.
1020+
"""
1021+
from langchain_core.messages import AIMessage, ToolMessage
1022+
1023+
msgs = state.get("messages", [])
1024+
for m in msgs[seen:]:
1025+
if isinstance(m, AIMessage):
1026+
tcs = m.tool_calls or []
1027+
if tcs:
1028+
for tc in tcs:
1029+
name = tc.get("name", "?")
1030+
args = tc.get("args", {}) or {}
1031+
preview = _preview_tool_args(name, args)
1032+
current_tool_calls[tc.get("id")] = f"{name}({preview})"
1033+
console.print(
1034+
f" [dim]→[/] [cyan]{name}[/]([dim]{preview}[/])"
1035+
)
1036+
if m.content:
1037+
# Final / mid-turn assistant text
1038+
from rich.markdown import Markdown
1039+
console.print()
1040+
console.print(
1041+
Panel(
1042+
Markdown(str(m.content).strip()),
1043+
border_style="green",
1044+
title="[bold]agent[/]",
1045+
title_align="left",
1046+
padding=(0, 1),
1047+
)
1048+
)
1049+
elif isinstance(m, ToolMessage):
1050+
preview = _preview_tool_result(m.content or "")
1051+
console.print(f" [dim]↳ {preview}[/]")
1052+
return len(msgs)
1053+
1054+
1055+
def _preview_tool_args(_name: str, args: dict) -> str:
1056+
if not args:
1057+
return ""
1058+
keys = ("file_path", "path", "pattern", "old_string", "new_string")
1059+
for k in keys:
1060+
if k in args:
1061+
v = str(args[k])
1062+
if len(v) > 60:
1063+
v = v[:57] + "…"
1064+
return f'{k}="{v}"'
1065+
rendered = ", ".join(f'{k}="{str(v)[:40]}"' for k, v in list(args.items())[:2])
1066+
return rendered[:80]
1067+
1068+
1069+
def _preview_tool_result(content: str) -> str:
1070+
s = str(content).strip().replace("\n", " ⏎ ")
1071+
if len(s) > 120:
1072+
s = s[:117] + "…"
1073+
return s
1074+
1075+
1076+
def _read_multiline(prompt: str = "[bold cyan]you ›[/] ") -> str | None:
1077+
"""Read input with \\-continuation + triple-backtick fence support.
1078+
1079+
Returns the composed string, or None on EOF/Ctrl-C at the very first
1080+
empty line (so callers can treat it as "quit").
1081+
"""
1082+
first = console.input(prompt)
1083+
if first.strip().startswith("```"):
1084+
# Fenced block — keep reading until the closing fence.
1085+
lines: list[str] = []
1086+
while True:
1087+
ln = console.input("[dim]… [/]")
1088+
if ln.strip() == "```":
1089+
break
1090+
lines.append(ln)
1091+
return "\n".join(lines)
1092+
if first.rstrip().endswith("\\"):
1093+
lines = [first.rstrip()[:-1]]
1094+
while True:
1095+
ln = console.input("[dim]… [/]")
1096+
if ln.rstrip().endswith("\\"):
1097+
lines.append(ln.rstrip()[:-1])
1098+
else:
1099+
lines.append(ln)
1100+
break
1101+
return "\n".join(lines)
1102+
return first
1103+
1104+
1105+
def _handle_slash(cmd: str, *, p: Paths, tid: str) -> tuple[str, bool]:
1106+
"""Handle a /slash command. Returns (new_thread_id, keep_running).
1107+
1108+
Returning `keep_running=False` signals the REPL should exit.
1109+
Commands that print info leave tid unchanged.
1110+
"""
1111+
import time
1112+
1113+
from thesis_agent.config import models
1114+
1115+
parts = cmd.strip().split(maxsplit=1)
1116+
name = parts[0].lower()
1117+
arg = parts[1].strip() if len(parts) > 1 else ""
1118+
1119+
if name in ("/quit", "/exit"):
1120+
return tid, False
1121+
1122+
if name == "/help":
1123+
console.print(Panel.fit(_CHAT_BANNER, border_style="dim"))
1124+
return tid, True
1125+
1126+
if name == "/new":
1127+
new_id = f"t-{int(time.time())}"
1128+
write_thread_id(new_id)
1129+
console.print(f"[dim]new thread:[/] [cyan]{new_id}[/]")
1130+
return new_id, True
1131+
1132+
if name == "/thread":
1133+
if not arg:
1134+
console.print(f"[dim]current thread:[/] [cyan]{tid}[/]")
1135+
return tid, True
1136+
write_thread_id(arg)
1137+
console.print(f"[dim]switched to thread:[/] [cyan]{arg}[/]")
1138+
return arg, True
1139+
1140+
if name == "/clear":
1141+
console.clear()
1142+
return tid, True
1143+
1144+
if name == "/model":
1145+
m = models()
1146+
tbl = Table(show_header=False, box=None, padding=(0, 1))
1147+
tbl.add_row("[bold]drafter[/]", m.drafter)
1148+
tbl.add_row("[bold]curator[/]", m.curator)
1149+
tbl.add_row("[bold]researcher[/]", m.researcher)
1150+
console.print(Panel(tbl, title="active models", border_style="dim"))
1151+
return tid, True
1152+
1153+
if name == "/status":
1154+
raw_count = (
1155+
sum(1 for f in p.raw.iterdir() if f.is_file() and not f.name.startswith("_"))
1156+
if p.raw.exists() else 0
1157+
)
1158+
wiki_total = sum(
1159+
1 for f in p.wiki.rglob("*.md") if f.is_file()
1160+
) if p.wiki.exists() else 0
1161+
chap_count = (
1162+
sum(1 for f in p.chapters.iterdir() if f.is_file() and f.suffix == ".md")
1163+
if p.chapters.exists() else 0
1164+
)
1165+
console.print(
1166+
f"[dim]thread:[/] [cyan]{tid}[/] [dim]raw:[/] {raw_count} "
1167+
f"[dim]wiki:[/] {wiki_total} [dim]chapters:[/] {chap_count}"
1168+
)
1169+
return tid, True
1170+
1171+
if name == "/history":
1172+
try:
1173+
n = int(arg) if arg else 10
1174+
except ValueError:
1175+
n = 10
1176+
# LangGraph checkpoints persist the thread; read back via the
1177+
# agent's checkpointer on next turn. For now we print a hint —
1178+
# the full `checkpointer.list()` API varies across langgraph
1179+
# versions; cheap path: tell the user we haven't implemented
1180+
# deep history retrieval.
1181+
console.print(
1182+
f"[dim]Thread {tid} has its state persisted in data/checkpoints.db. "
1183+
f"History browsing in this REPL is limited to the current session; "
1184+
f"requested last {n} messages — use `/clear` + re-ask to reconstruct.[/]"
1185+
)
1186+
return tid, True
1187+
1188+
console.print(f"[yellow]unknown command:[/] {name} [dim](try /help)[/]")
1189+
return tid, True
1190+
1191+
9941192
@app.command()
9951193
def chat(
9961194
thread: str = typer.Option(None, "--thread", help="Thread ID (defaults to persisted)."),
9971195
new: bool = typer.Option(False, "--new", help="Start a fresh thread."),
1196+
quiet: bool = typer.Option(
1197+
False, "--quiet",
1198+
help="Suppress tool-call traces; only show assistant replies.",
1199+
),
9981200
) -> None:
999-
"""Interactive REPL with the agent. Ctrl-D / Ctrl-C to quit."""
1000-
from thesis_agent.agent import build_agent
1201+
"""Interactive terminal UI with streaming + slash commands.
1202+
1203+
Claude-Code-style: each user turn streams incrementally, tool calls
1204+
render inline, the final assistant reply appears in a markdown panel.
1205+
"""
1206+
import time
1207+
1208+
from thesis_agent.agent import _recursion_limit, build_agent
10011209

10021210
p = paths()
10031211
if new:
1004-
import time
10051212
tid = f"t-{int(time.time())}"
10061213
else:
10071214
tid = thread or read_thread_id()
10081215
write_thread_id(tid)
10091216

1010-
console.print(
1011-
Panel.fit(
1012-
f"thread: [cyan]{tid}[/] type [bold]/new[/] for a fresh thread, "
1013-
f"[bold]/quit[/] to exit.",
1014-
border_style="dim",
1015-
)
1016-
)
1217+
console.print(Panel.fit(_CHAT_BANNER, border_style="cyan"))
1218+
console.print(f"[dim]thread:[/] [cyan]{tid}[/]\n")
10171219

10181220
with build_agent(p=p) as agent:
10191221
while True:
10201222
try:
1021-
msg = console.input("[bold cyan]you >[/] ")
1223+
msg = _read_multiline()
10221224
except (EOFError, KeyboardInterrupt):
10231225
console.print("\n[dim]bye.[/]")
10241226
break
1025-
if not msg.strip():
1227+
1228+
if msg is None:
10261229
continue
1027-
if msg.strip() in {"/quit", "/exit"}:
1028-
break
1029-
if msg.strip() == "/new":
1030-
import time
1031-
tid = f"t-{int(time.time())}"
1032-
write_thread_id(tid)
1033-
console.print(f"[dim]new thread: {tid}[/]")
1230+
msg_stripped = msg.strip()
1231+
if not msg_stripped:
1232+
continue
1233+
if msg_stripped.startswith("/"):
1234+
tid, keep = _handle_slash(msg_stripped, p=p, tid=tid)
1235+
if not keep:
1236+
console.print("[dim]bye.[/]")
1237+
break
10341238
continue
10351239

1240+
config = {
1241+
"configurable": {"thread_id": tid},
1242+
"recursion_limit": _recursion_limit(),
1243+
}
1244+
seen = 0
1245+
current_tools: dict = {}
10361246
try:
1037-
from thesis_agent.agent import _recursion_limit
1038-
result = agent.invoke(
1247+
for state in agent.stream(
10391248
{"messages": [{"role": "user", "content": msg}]},
1040-
config={
1041-
"configurable": {"thread_id": tid},
1042-
"recursion_limit": _recursion_limit(),
1043-
},
1044-
)
1045-
msgs = result.get("messages", [])
1046-
if msgs:
1047-
last = msgs[-1]
1048-
content = getattr(last, "content", None) or last.get("content", "")
1049-
console.print(f"[bold green]agent >[/] {content}\n")
1249+
config=config,
1250+
stream_mode="values",
1251+
):
1252+
if quiet:
1253+
seen = len(state.get("messages", []))
1254+
continue
1255+
seen = _render_new_messages(state, seen, current_tools)
1256+
if quiet and state.get("messages"):
1257+
# Print only the final assistant content in quiet mode.
1258+
from langchain_core.messages import AIMessage
1259+
from rich.markdown import Markdown
1260+
last = state["messages"][-1]
1261+
if isinstance(last, AIMessage) and last.content:
1262+
console.print(Panel(Markdown(str(last.content)),
1263+
border_style="green",
1264+
title="[bold]agent[/]",
1265+
title_align="left"))
1266+
except KeyboardInterrupt:
1267+
console.print("\n[yellow]turn cancelled.[/] "
1268+
"[dim](Ctrl-C again to quit)[/]")
10501269
except Exception as e:
10511270
console.print(f"[red]agent error:[/] {e}")
1271+
console.print() # breathing room before next prompt
10521272

10531273

10541274
@app.callback(invoke_without_command=True)

src/thesis_agent/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,9 @@ def load_env(required_api_key: bool = True) -> None:
229229
researcher="anthropic:claude-haiku-4-5-20251001",
230230
),
231231
"openrouter": ModelConfig(
232-
drafter="z-ai/glm-5.1",
233-
curator="z-ai/glm-5.1",
234-
researcher="google/gemma-4-31b-it",
232+
drafter="google/gemma-4-31b-it:free",
233+
curator="google/gemma-4-31b-it:free",
234+
researcher="google/gemma-4-31b-it:free",
235235
),
236236
}
237237

0 commit comments

Comments
 (0)