diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a1b470..4355d92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -252,8 +252,10 @@ set(SRCS src/shell_env.cpp src/preferences.cpp src/settings_dialog.cpp + src/project_brief.cpp src/streaming_web_request.cpp src/run_config_store.cpp + src/thread_list.cpp ) # macOS-only native styling (Objective-C++); see src/mac_style.h. diff --git a/README.md b/README.md index d82a1c0..d58d07f 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,11 @@ Get both from the [latest release](https://github.com/lszl84/gritcode/releases/l - **An agent with real tools:** bash, read, write and edit files, list, grep, web search and URL fetch, with collapsible tool-call cards and thinking blocks - **▶ Play:** the agent saves your project's run command, then one click builds and runs it with no AI round-trip. Output lands in the chat. - **Built-in editor:** a file tree plus tree-sitter syntax highlighting, smart indentation and save -- **Sessions:** per-project history in SQLite. Export a session to a `.gritsession` file (images included) and replay its prompts in a new project. -- **Cross-project memory:** full-text search over past sessions, available to the agent through `grit_history_search` and `grit_history_fetch` +- **Threads:** several conversations per folder, all running at once, so an unrelated task starts with a clean context. Switch between them in the left pane; a mark tells you when one finished or failed while you were elsewhere. +- **Subagents:** DeepSeek and Kilo models can hand work to subagents running in the background, and Claude Code's own subagents show up too. Each one is a thread under its parent that you can open and follow live. +- **Project brief:** the first new thread in a folder writes a short brief of the project, and every new thread starts with it +- **Sessions:** history in SQLite. Export a thread to a `.gritsession` file (images included) and replay its prompts in a new project. +- **Cross-project memory:** full-text search over past conversations, available to the agent through `grit_history_search` and `grit_history_fetch`, across all projects or just the folder's other threads - **Cheap long sessions:** compaction only near the context limit, a token-budgeted tail and chained summaries - **Vision:** drag screenshots into the message box - **Streaming markdown:** headings, tables and code blocks render live @@ -68,7 +71,7 @@ Click the ⚙ gear button in the bottom toolbar to open Settings, then paste you The Model dropdown also offers **Claude (Opus 5.5)** and **Claude (Sonnet 5)**. These run your installed [Claude Code](https://code.claude.com/docs/en/setup) (the `claude` command) headlessly, so you need Claude Code installed and signed in: run `claude` once in a terminal and log in. Gritcode never sees your Claude credentials; usage goes to your own Claude account or API key. -With Claude selected, Claude Code runs its own agent loop and tools (Bash, Read, Edit, …), and Gritcode shows them in the chat. As with Gritcode's own agent, tools run without confirmation prompts. The ▶ Play button works the same with Claude: Gritcode gives Claude its `run_project` tool so it can set up the run command. Each Gritcode session resumes the same Claude Code session, and when you switch models mid-session the other model gets a transcript of what happened. Claude's effort level is in Settings. +With Claude selected, Claude Code runs its own agent loop and tools (Bash, Read, Edit, …), and Gritcode shows them in the chat. As with Gritcode's own agent, tools run without confirmation prompts. The ▶ Play button works the same with Claude: Gritcode gives Claude its `run_project` tool so it can set up the run command. Each Gritcode thread resumes its own Claude Code session, and when you switch models mid-thread the other model gets a transcript of what happened. Claude Code's subagents appear as threads under the one that started them. The project brief is written with Sonnet through the same Claude Code install, so nothing goes to another provider. Claude's effort level is in Settings. ## Support Gritcode diff --git a/assets/icons/session.svg b/assets/icons/session.svg new file mode 100644 index 0000000..9b7e1a8 --- /dev/null +++ b/assets/icons/session.svg @@ -0,0 +1,4 @@ + + + diff --git a/assets/icons/threads.svg b/assets/icons/threads.svg new file mode 100644 index 0000000..86912b6 --- /dev/null +++ b/assets/icons/threads.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/design/orchestrator-conversation.md b/docs/design/orchestrator-conversation.md new file mode 100644 index 0000000..0250303 --- /dev/null +++ b/docs/design/orchestrator-conversation.md @@ -0,0 +1,108 @@ +# Orchestrator conversation model (ideas, not decided) + +Problem: while work is running, the user wants to keep talking to the +orchestrator. A common answer is a server-side message queue plus +"steer" (inject a queued message into the active turn) and "interrupt". That +makes the user classify every message's urgency. These are alternatives to +explore for Gritcode's thread runtime. + +## 1. Orchestrator that's always free to talk +The orchestrator never does long work itself; it delegates everything to +worker threads. It is therefore never mid-turn, so messages to it never queue. +Queue/steer only exists at the worker level, and the orchestrator decides +which applies, not the user. + +## 2. Route by intent instead of a mode button +The user just talks. The orchestrator works out what each message is: +- question → answered from the current state of the workers, no worker disturbed +- correction → passed to the affected worker immediately +- new work → a new thread or card +- change of plan → existing tasks updated, and the workers told +It shows one line saying what it did ("→ sent to *storage migration*"), +so a wrong guess is easy to spot. + +## 3. Steer through shared state, not messages +Workers re-read a shared plan/board file (e.g. `.grit/board.md`) at every +step. The user or the orchestrator edits the plan; workers pick up the change +at their next step. Quieter than interrupting, leaves a record, and links up +with the planned kanban (cards hold their own instructions and limits). + +## 4. Workers confirm corrections +A worker states its current step and confirms each correction ("got it, +switching after this edit"), so the user knows the correction landed. + +## 5. Two separate streams +The conversation with the orchestrator stays clean; worker progress appears as +a status feed in a side pane, not mixed into the chat. Fits the default +chat-window-plus-optional-panes UI. + +## 6. The orchestrator as a visible, personable guide +Luke's idea: the orchestrator is a named host for the project, not a hidden +mechanism. It greets you, e.g. "Hi! I'm the orchestrator for this project. I'll +start and watch your agents, report progress and guide you through the work. +Meanwhile, ask me anything about the project." +- It is the default view: the plain chat window *is* the orchestrator, and + worker threads appear as things it manages. +- Personality should come from usefulness (proactive status, raising + decisions, explaining what each agent is doing), not from chatter. Tone + could be user-configurable. +- Cheap model for orchestration (it mostly routes and summarises), strong + models for workers. This fits DeepSeek's prices: Flash as host, Pro or + Claude as workers. +- It decides when to interrupt you (decisions, failures, done) versus when to + stay quiet. Batching notifications is part of its job. +- The main risk is the orchestrator getting chatty or making decisions it + should hand to you. It needs a clear rule: it proposes, you decide anything + hard to undo. + +## 7. Agent map: a zoomable radial view of agents +Luke's idea: instead of a lineage list, draw the agents +as a graph. The orchestrator is a circle at the centre, worker threads are +circles around it, and their subagents branch out as smaller circles. You can +zoom out for the big picture and zoom in on one branch. +- **Semantic zoom:** zoomed out, each circle shows only its state (colour, + pulse while running); in the middle range, name plus current step; zoomed + in, a live tail of the transcript and buttons (open, steer, cancel). +- **Visual encoding:** circle size = tokens or cost so far; an outer ring = + progress or remaining budget; colour = state (running, waiting for the write + lock, needs you, done, failed). Edges briefly light up when a result flows + back to the parent. +- **Linking:** clicking a circle selects that thread in the chat; it is the + same thread model as the thread list, drawn differently. Circles that need a + decision from you stand out, so the map doubles as an inbox. +- **Feasibility:** Gritcode already draws its chat on a custom canvas + (`chat_canvas.cpp`), so a native wxGraphicsContext view with a radial tree + layout and pan/zoom is realistic, with no web view needed. It belongs in a + side pane (IDE phase), built on WP5c's child-thread data. +- Possible prior art to look at: Gource (a radial, animated tree of a + repository's history), d3's zoomable circle packing and radial trees, and the + graph views in agent-workflow tools such as LangGraph Studio. + +## 8. Decisions never get buried +Luke's observation: in a chat-only orchestrator, open questions and +decisions for the user scroll away and get lost in the conversation. +- Pending decisions are first-class objects, not chat text: a pinned + "Needs you" tray (or the most prominent nodes on the agent map, idea 7) + that stays visible until each one is resolved. +- Each decision card shows: the question, who is blocked by it (which + threads/packages, and from which wave), the recommended option with + one-line trade-offs, and one-click answers ("defaults OK" for a batch). +- Cards carry a deadline in workflow terms ("blocks wave E"), so the + orchestrator keeps working on everything that isn't blocked and only + escalates when a card actually blocks progress. +- Answers are recorded with the decision (an audit trail in the project, e.g. + `.grit/decisions.md`) and fed to the affected workers automatically. +- Layout: the agent map plus the "Needs you" tray in front, and the chat as + a side or bottom panel for asking questions, which turns the current + layout around once orchestration is in use. + +## Gritcode advantage +A single native process holds all threads, so the orchestrator can read every +worker's state directly (no server or remote calls in between). Answering +questions from current state is fast and cheap. + +## What the thread runtime needs to allow these +- Accept input at step boundaries (between model calls / tool calls) for both + the DeepSeek loop and the Claude stream-json driver. +- Plan/board state shared between threads and readable by tools. +- A per-thread "current step" status the orchestrator can query. diff --git a/docs/design/threads.md b/docs/design/threads.md new file mode 100644 index 0000000..f20b1b5 --- /dev/null +++ b/docs/design/threads.md @@ -0,0 +1,212 @@ +# Threads: lean design + +Goal: many independent conversations ("threads") per project folder, able to +run at the same time, later with subagents. Small, fast, data-oriented. + +Rules: plain structs and free/member functions on data. No new class +hierarchies, virtual interfaces, managers, executors or event buses. Use C++20 +and the standard library well. Aim for the simplest clean solution that reads +naturally; no code golf, and no speculative generality. + +## Data + +- `struct Thread` (in `chat_frame.h`) holds what is per-conversation state + today as `ChatFrame` members: id, cwd, title, parent id, history, the + in-flight `StreamingWebRequest`, `ClaudeAgentProcess`, tool worker and + cancel token, SSE and tool-call buffers, compaction state, Claude turn state, + queued messages, flags (streaming, compacting, ...), plus `uint32_t gen`. +- `ChatFrame` owns `std::deque threads_` (stable addresses on + push_back) and `size_t active_`, the thread shown on the canvas. +- Process-wide state stays in `ChatFrame`: model list, preferences, memory DB, + editor, canvas, persist worker. + +## Async results + +Every callback captures `(thread id, gen)` instead of relying on members: +`[this, id, gen](auto... a) { if (Thread* t = Find(id, gen)) OnX(*t, a...); }`. +`gen` is bumped whenever a thread's request/process/worker is replaced or the +thread is removed, so stale replies are dropped. This also fixes the existing +"queued reply after the request was destroyed" hazard. + +## Rendering + +Only the active thread draws to the canvas. Background threads update data +only. Switching threads rebuilds the canvas from history (as `RestoreSession` +does today) plus the in-flight partial text. + +## Tools + +`DispatchTool` gets the thread's cwd: relative paths resolve against it and +bash `chdir`s in the forked child. The process-wide `ChdirToCwd` goes away. + +## Write safety + +Threads in one folder run at the same time, with no lock, so the agents +keep out of each other's way themselves, without the user having to think +about worktrees: + +- A top-level thread in a git repo gets rules in its system prompt: other + threads may share this checkout, so never change its branch or index + (switch, stash, reset, rebase, pull); work in place by default and re-read + a file right before editing it; use a git worktree + (`/worktrees//`) only when the user wants the work + on its own branch, or when another thread is working here right now and + the change is more than a small edit; when done there, commit and tell the + user how to merge, and leave the worktree. +- "Right now" comes from a note on the user's message, written when the turn + starts: the titles of the other top-level threads running in the same + checkout (the repo, so subfolders count), or that none is any more. It is + stored as the message's `note` only when it differs from the last note the + thread got, and added after the message for the model (`[gritcode: ...]`). + The canvas doesn't show it (the thread list shows what runs), and the + prompt tells the model not to bring it up unless it matters. No note, no + reason for a worktree, so most work makes none. + +Subagents are read-only: `write_file`, +`edit_file` and `run_project` are neither offered to them nor run for them, +and their prompt tells them to use bash only to inspect (a convention, not +enforced). The parent makes the edits from their reports, so concurrent +subagents never write under each other. + +## Subagents + +A subagent is a child thread (`parent` set), one level deep, view-only, listed +under its parent in the thread list and linked from the parent's tool block. + +- DeepSeek/Kilo: top-level threads get a `task` tool (description, prompt). + Each call opens a child thread in the same folder with a fresh context (base + system prompt plus a subagent note) that runs headless. A Pro parent's child + uses the matching Flash model, otherwise the parent's model. The calls of + one batch run at once, alongside the batch's other tools; the batch ends when + the tool worker and all children are done, and the results go into the + parent's history in call order: the child's final message, or the failure. + Cancelling the parent cancels its children; cancelling a child alone gives + its parent an error result. A failed child keeps its history. +- Claude Code: an `Agent` (older CLIs: `Task`) tool call opens a child thread + titled by its description, and events with that `parent_tool_use_id` are + assembled into it by the same code as the top level. It ends with the call's + result, or, for a background agent (the result only says it started), with + `system/task_notification`; a failure is also appended to the parent's + recorded result. Cancelling a child cancels the parent's Claude Code run; + children still running when that run ends are marked stopped. +- The child's id is recorded on the parent's tool call (`"thread"`, kept off + the wire), so restored parents still link to their children. + +## Storage (no migration) + +Session files keep their format: `sessions/.json` with `id`, `cwd`, +`lastUsed`, `messages`, plus optional `title` and `parent`. The existing +session of a folder (id = hash of cwd) simply becomes its first thread. The +index keeps `sessions` (one entry per folder, exactly what 0.5.x reads) and +adds `threads` for additional threads, which 0.5.x ignores. Downgrading keeps +working: old versions see every folder's first thread with full history. + +## Project brief + +A thread starts without the history of the folder's other threads. The brief +gives it the background instead: a few paragraphs on what the project is, its +layout and conventions, and the current work. `SystemPrompt` composes the +base prompt, AGENTS.md, the brief and a list of the folder's other titled +threads (title and day last used, at most 10). Titles and days only: running +state changes every turn and would keep changing the cached prompt prefix. A +thread's prompt is rebuilt when it opens and when AGENTS.md changes, and a +thread with no turn yet gets the brief as soon as it is written; Claude turns +get the same text with each run. + +Storage: `briefs/.md` in the app data dir, next to `sessions/` and +keyed like a folder's first thread. Gritcode doesn't write into the user's +repo unasked, and AGENTS.md already is the versioned, in-repo place for +project instructions (it is injected too). No brief means no section, as +before. + +The brief is written automatically, once: when the user starts a new thread +in a folder that has none, it is written in the background from the +top-level entries, README, AGENTS.md and thread titles, and every later new +thread there reuses it. It isn't editable for now. The writer is from the +provider of the selected model, so the material goes nowhere the user's +chats don't (`BriefModelIndex`): DeepSeek's Flash model for DeepSeek (cheap +and fast), Sonnet through the Claude Code CLI for Claude (`claude -p +--output-format json --tools "" --no-session-persistence`, the material on +stdin, so no tools run and no session is kept), and the free model for +itself. + +Nothing about it is silent: the new thread says it is being written and by +which model, then shows the brief it starts with (every new thread shows the +brief it starts with); a failure is shown with the model's error, and the +next new thread tries again. Opening a folder or a saved thread writes +nothing. + +## History search + +Every thread's turns are indexed in `memory.db` under its own id when it is +saved, and `--reindex` walks every session file. `grit_history_search` leaves +out the calling thread (its history is already in context), takes `scope: +"folder"` to keep to the thread's folder (default: all folders), and shows +each hit's thread title, marking subagents. Over `--mcp-stdio` the folder is +the server's working directory and nothing is excluded. The tool description +and the "other threads" list in the prompt point a fresh thread at its +folder's other threads. + +Storage: titles and parents live in a new plain table `threads(session_id, +title, parent)` next to the FTS5 `turns` table, which keeps the exact 0.5.x +schema. 0.5.x only creates, reads and writes `turns`, so it ignores the new +table, and old rows need no rebuild: a session without a `threads` row (one +indexed by 0.5.x or before this step) shows no title until its next save or +a `--reindex`. Nothing is deleted except a session's own rows on its +rebuild, as before; turns of sessions with no file any more stay searchable. + +## UI + +The session dropdown stays at the folder level, as in 0.5.x: "New Session…" +(pick a folder), then the folders, most recent first. Picking a folder opens +its most recently used thread. A mark before a folder says there is something +there the user hasn't seen: "⚠" a thread failed, "●" one is working or +finished since it was last viewed. The thread on screen never counts, so the +closed dropdown (the current folder) is the in-folder indicator; subagents +report through their parent; a turn the user stopped leaves nothing to see. +The Threads list marks the same threads. In memory only (`Thread::unseen`). + +Threads are switched in the existing left pane (hamburger toggle). Its +bottom toolbar, like the main pane's, has two view icons, the current one in +the text colour (remembered in preferences), and "+" (New thread) on the +right in the Threads view. Session is the 0.5.x session reference, +unchanged. Threads lists the folder's threads: per thread a state mark +(● running, ⚠ failed until viewed) with the title, +subagents indented under their parent, and a dim line below: while running +the current step (the tool it runs, "writing", "thinking"), after a failure its notice, otherwise when it was last used. The +rows come from `FolderRows`; the step is derived from the thread's existing +fields (pending tool calls in history or Claude's pending message, in-flight +text), and a 1 s timer refreshes the list only while it is on screen and a +thread of the folder runs. Clicking a row selects the thread; right-click on +a running one offers "Stop". The input belongs to its thread: switching away keeps the +draft (text and attached images) with the thread you leave and brings back +the one you return to. Play runs one command per folder, each on its own +worker, so Play in one project never stops another's run (Play again in a +folder restarts its run); its blocks are display-only, kept with the thread +Play was pressed in (`localBlocks`, put back in place when the canvas is +rebuilt), never sent to the model or saved. The agent map and the decision tray come later (see +`orchestrator-conversation.md`). + +## Steps + +1. Threads: struct, multi-thread runtime, store, per-thread cwd, + thread switching. (done) +2. Subagents: a DeepSeek `task` tool running a child thread headless, and + Claude Code subagent events shown as child threads. (done) +3. Fixes that matter once threads run at once (found on the old branch): a + bash timeout that kills the whole process group, a lock around run-config + read-modify-write, SSE parsing of CRLF streams / a final event without a + blank line / `data:` without a space, and "Refresh" in the file tree saving + the open file. +4. Project brief: a short per-folder brief, written automatically at the + first new thread, and a list of the folder's other threads in every + thread's system prompt. (done) +5. Thread list: a Threads view in the left pane listing the folder's threads + and their child threads with state (running, failed, done); the + session dropdown stays per folder. (done) +6. Worktree threads: a thread can run in its own git worktree, so several + threads write in one project at once; diff and merge back. Built, then + taken out for now (merge 30541db, reverted); comes back from there. +7. Project-scoped history search for the memory tool. (done) + +The orchestrator ideas (`orchestrator-conversation.md`) build on 2 and 5. diff --git a/scripts/grit.py b/scripts/grit.py index 3f1ce18..4ee09e3 100644 --- a/scripts/grit.py +++ b/scripts/grit.py @@ -9,6 +9,9 @@ python3 scripts/grit.py last # last assistant text python3 scripts/grit.py conversation # full history JSON python3 scripts/grit.py cancel # cancel in-flight request + python3 scripts/grit.py threads # threads, all folders + python3 scripts/grit.py newthread [cwd] # new thread (active folder by default) + python3 scripts/grit.py thread # make thread active """ import json @@ -18,13 +21,17 @@ class Grit: - def __init__(self, host="127.0.0.1", base_port=8765): + # `port` connects to that port only. Tests must pass it: without it the + # client also tries the next five ports, where the user's own gritcode + # windows listen (8765, 8766, ...). + def __init__(self, host="127.0.0.1", base_port=8765, port=None): self.host = host - self.base_port = base_port + self.base_port = port or base_port + self.ports = [port] if port else range(base_port, base_port + 6) self._id = 0 def _call(self, method, params=None, timeout=10): - for p in range(self.base_port, self.base_port + 6): + for p in self.ports: try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) @@ -33,7 +40,7 @@ def _call(self, method, params=None, timeout=10): except (ConnectionRefusedError, OSError): s.close() else: - raise ConnectionError(f"no MCP server on {self.host}:{self.base_port}-{self.base_port+5}") + raise ConnectionError(f"no MCP server on {self.host}:{list(self.ports)}") self._id += 1 req = {"jsonrpc": "2.0", "id": self._id, "method": method} @@ -85,6 +92,15 @@ def switch_session(self, cwd): def new_session(self): return self._call("newSession") + def list_threads(self): + return self._call("listThreads") + + def new_thread(self, cwd=""): + return self._call("newThread", {"cwd": cwd}) + + def select_thread(self, thread_id): + return self._call("selectThread", {"id": thread_id}) + def set_model(self, idx): return self._call("setModel", {"index": idx}) @@ -139,6 +155,16 @@ def main(): print(json.dumps(g.switch_session(sys.argv[2]), indent=2)) elif cmd == "new": print(json.dumps(g.new_session(), indent=2)) + elif cmd == "threads": + print(json.dumps(g.list_threads(), indent=2)) + elif cmd == "newthread": + print(json.dumps(g.new_thread(sys.argv[2] if len(sys.argv) > 2 else ""), indent=2)) + elif cmd == "thread": + if len(sys.argv) < 3: + print("usage: thread ") + sys.exit(1) + print(json.dumps(g.select_thread(sys.argv[2]), indent=2)) + elif cmd == "model": if len(sys.argv) < 3: print("usage: model (0=OpenCode Free, 1=DeepSeek Flash, 2=DeepSeek Pro)") diff --git a/scripts/smoke/brief.py b/scripts/smoke/brief.py new file mode 100644 index 0000000..f3d494f --- /dev/null +++ b/scripts/smoke/brief.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Smoke test for the project brief: written when a new thread starts in a +folder without one, reused by the folder's later new threads, and what goes +into a thread's system prompt. + +Usage: + python3 scripts/smoke/brief.py build/gritcode + +Runs the app only through ~/.local/share/grit-dev/run-hidden.sh (invisible +Xvfb, temp HOME and data dir), with the free model pointed at a local fake +(fake_model.py), which answers a brief request with the material it got, +and a stand-in `claude` on PATH for the Claude case. +""" + +import json +import os +import random +import shutil +import socket +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +from grit import Grit # noqa: E402 +from fake_model import Handler, serve # noqa: E402 + +LAUNCHER = os.path.expanduser("~/.local/share/grit-dev/run-hidden.sh") +BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build/gritcode") +DATA = tempfile.mkdtemp(prefix="grit-brief-") +PORT = int(os.environ.get("GRIT_MCP_PORT") or random.randint(18800, 18899)) +failures = 0 + + +def check(cond, what): + global failures + print(("ok " if cond else "FAIL ") + what) + failures += not cond + + +def system_prompt(g, thread_id): + g.select_thread(thread_id) + return g.conversation()[0]["content"] + + +def previews(g): + return " ".join(b["preview"] for b in g.blocks()) + + +def wait_for(cond, timeout=20): + end = time.time() + timeout + while time.time() < end: + if cond(): + return True + time.sleep(0.2) + return False + + +P, Q = os.path.join(DATA, "P"), os.path.join(DATA, "Q") +os.makedirs(P) +os.makedirs(Q) +with open(os.path.join(P, "AGENTS.md"), "w") as f: + f.write("agents-marker: run make test\n") +with open(os.path.join(P, "README.md"), "w") as f: + f.write("readme-marker: a C++ chat app.\n") +# The fake model answers this folder's brief request with HTTP 500. +with open(os.path.join(Q, "README.md"), "w") as f: + f.write("FAIL-500\n") +# Folder R is worked on with Claude: its brief must go through the Claude Code +# CLI (here a stand-in that records what it got), never to the HTTP model. +R = os.path.join(DATA, "R") +os.makedirs(R) +with open(os.path.join(R, "README.md"), "w") as f: + f.write("claude-readme-marker\n") +FAKEBIN = os.path.join(DATA, "fakebin") +os.makedirs(FAKEBIN) +with open(os.path.join(FAKEBIN, "claude"), "w") as f: + f.write("""#!/bin/bash +d=$(dirname "$0") +printf '%s\\n' "$@" > "$d/args.txt" +cat > "$d/stdin.txt" +python3 -c 'import json,sys; print(json.dumps({"type": "result", "is_error": False, + "result": "Claude brief. " + open(sys.argv[1]).read().splitlines()[0]}))' "$d/stdin.txt" +""") +os.chmod(os.path.join(FAKEBIN, "claude"), 0o755) +STUBS = os.path.expanduser("~/.local/share/grit-dev/stubs") +# Folder P has two threads; the first has no id, so gritcode derives it from +# the folder, which also names the folder's brief file. +os.makedirs(os.path.join(DATA, "gritcode")) +with open(os.path.join(DATA, "gritcode", "sessions.json"), "w") as f: + json.dump({"sessions": [{"cwd": P, "lastUsed": "2026-10-18T10:00:00", + "title": "Fix the SSE parser"}], + "threads": [{"id": "00000000000000aa", "cwd": P, + "lastUsed": "2026-10-19T09:00:00", + "title": "Add dark mode"}], + "lastActiveCwd": P}, f) +BRIEFS = os.path.join(DATA, "gritcode", "briefs") + +env = dict(os.environ, GRIT_TEST_DATA=DATA) +pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", + f"GRIT_FREE_MODEL_URL={serve()}", + f"PATH={FAKEBIN}:{STUBS}:{os.environ['PATH']}"], + env=env, stderr=subprocess.DEVNULL)) +try: + # Wait for this instance's own port (Grit would also try the next ones). + for _ in range(100): + try: + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + break + except OSError: + time.sleep(0.2) + g = Grit(port=PORT) + threads = g.list_threads()["threads"] + first = next(t["id"] for t in threads if t["cwd"] == P and t["id"] != "00000000000000aa") + brief_file = os.path.join(BRIEFS, first + ".md") + + print("-- no brief") + sp = system_prompt(g, "00000000000000aa") + check("agents-marker" in sp, "AGENTS.md is still injected") + check("Fix the SSE parser (last used 2026-10-18)" in sp, "the other thread is listed") + check("Add dark mode" not in sp, "the thread itself is not listed") + check("Project brief" not in sp, "no brief section") + check("project brief" not in previews(g), "opening a thread writes no brief") + + print("-- a new thread writes it") + fresh = g.new_thread(P)["threadId"] + check("Writing a project brief for this folder with" in previews(g), + "the new thread says a brief is being written, and by which model") + check(wait_for(lambda: os.path.exists(brief_file)), "the brief is saved") + brief = open(brief_file).read() if os.path.exists(brief_file) else "" + check(brief.startswith("Fake brief.") and "readme-marker" in brief + and "agents-marker" in brief and "Fix the SSE parser" in brief, + "it was written from the README, AGENTS.md and the thread titles") + check(wait_for(lambda: "starts with the folder's project brief" in previews(g) + and "Writing a project brief" not in previews(g)), + "the thread then shows the brief it starts with") + sp = system_prompt(g, fresh) + check("Fake brief." in sp and "agents-marker" in sp, "the brief is in its system prompt") + check("Fix the SSE parser" in sp and "Add dark mode" in sp, "it lists both threads") + + print("-- the next new thread reuses it") + mtime = os.path.getmtime(brief_file) + g.new_thread(P) + p = previews(g) + check("starts with the folder's project brief" in p and "Writing" not in p, + "it starts with the saved brief right away") + time.sleep(2) + check(os.path.getmtime(brief_file) == mtime, "the brief is not written again") + + print("-- a failed brief") + q = g.switch_session(Q)["threadId"] + sp = system_prompt(g, q) + check("Fake brief" not in sp and "Other threads" not in sp, "nothing from P") + g.new_thread(Q) + check(wait_for(lambda: "couldn't write the project brief" in previews(g)), + "the failure is shown") + check("scripted failure" in previews(g), "with the model's error") + check(os.listdir(BRIEFS) == [first + ".md"], "no brief is saved for the folder") + + print("-- Claude writes a Claude user's brief") + g.switch_session(R) + # Dropdown row 3: after Kilo Free and the two fallback DeepSeek models. + check(g.set_model(3).get("modelIndex") == 100, "Claude (Opus) is selected") + g.new_thread(R) + check(wait_for(lambda: "Writing a project brief for this folder with Claude (Sonnet" + in previews(g) or len(os.listdir(BRIEFS)) == 2), + "it is written by Claude's Sonnet") + check(wait_for(lambda: len(os.listdir(BRIEFS)) == 2), "the brief is saved") + saved = [n for n in os.listdir(BRIEFS) if n != first + ".md"] + brief = open(os.path.join(BRIEFS, saved[0])).read() if saved else "" + check(brief == "Claude brief. Folder: " + R, f"from the CLI's result ({brief[:60]})") + args = open(os.path.join(FAKEBIN, "args.txt")).read().split("\n") + check(args[:1] == ["-p"] and "claude-sonnet-5" in args, "a print-mode run on Sonnet") + check("--tools" in args and args[args.index("--tools") + 1] == "" + and "--no-session-persistence" in args, "with no tools, not saved as a session") + check("claude-readme-marker" in open(os.path.join(FAKEBIN, "stdin.txt")).read(), + "the material goes in on stdin") + check(not any(R in json.dumps(m) for m in Handler.requests), + "nothing about the folder reached the other model") +finally: + os.kill(pid, 15) + +print(f"{failures} failure(s); data in {DATA}" if failures else "all ok") +if not failures: + shutil.rmtree(DATA, ignore_errors=True) +sys.exit(1 if failures else 0) diff --git a/scripts/smoke/fake_model.py b/scripts/smoke/fake_model.py new file mode 100755 index 0000000..00570ab --- /dev/null +++ b/scripts/smoke/fake_model.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +A fake OpenAI-compatible model for the smoke tests: POST /chat/completions +answers with a scripted SSE stream, picked by the last message: + +- a user message "Call the bash tool now with exactly this command: CMD" + calls bash with CMD (the rest of that line); +- a user message "Call these tools now: [[name, {args}], ...]" calls those + tools, all in one response; +- tool results get the text "Results: | ..."; +- a project brief request (its system prompt starts "You write the project + brief") gets "Fake brief. " followed by the material it was sent; +- anything else gets the text "ok". + +Markers in the last user message, before any of those "Call ..." lines (so a +subagent's prompt inside a task call doesn't count for the parent), change the +replies: FAIL-500 answers that message with HTTP 500, SSE-CRLF ends lines with +CRLF and writes `data:` without a space, SSE-UNTERMINATED leaves out [DONE] and +the blank line after the last event. + +Every reply waits `delay` seconds first, so turns overlap and the app's +1-second lastUsed timestamps differ between turns. + +Usage: fake_model.py [delay] (prints the URL; the smoke tests import serve()) +""" + +import json +import re +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# The first of these in a prompt counts (a task's prompt can hold another). +DIRECTIVE = re.compile("(Call the bash tool now with exactly this command|Call these tools now): (.*)") + + +def text_of(message): + content = message.get("content") or "" + if isinstance(content, list): # [{"type": "text", "text": ...}, ...] + return "".join(part.get("text", "") for part in content) + return content + + +def reply_for(messages): + """The delta of the reply: {"content": ...} or {"tool_calls": [...]}.""" + last = messages[-1] + if text_of(messages[0]).startswith("You write the project brief"): + return {"content": "Fake brief. " + text_of(last)} + if last["role"] == "tool": + results = [] + for m in reversed(messages): + if m["role"] != "tool": + break + results.insert(0, text_of(m).strip()) + return {"content": "Results: " + " | ".join(results)} + m = DIRECTIVE.search(text_of(last)) + if not m: + return {"content": "ok"} + calls = [["bash", {"command": m[2]}]] if "bash" in m[1] else json.loads(m[2]) + return {"tool_calls": [ + {"index": i, "id": f"call_{time.monotonic_ns()}_{i}", "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}} + for i, (name, args) in enumerate(calls)]} + + +def sse(delta, crlf, unterminated): + finish = "tool_calls" if "tool_calls" in delta else "stop" + events = [ + {"choices": [{"index": 0, "delta": {"role": "assistant"}}]}, + # The last event carries everything, so dropping it loses the reply. + {"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}, + ] + payloads = [json.dumps(e) for e in events] + ([] if unterminated else ["[DONE]"]) + eol, field = ("\r\n", "data:") if crlf else ("\n", "data: ") + body = (eol + eol).join(field + p for p in payloads) + return body if unterminated else body + eol + eol + + +class Handler(BaseHTTPRequestHandler): + delay = 1.1 + requests = [] # every request's messages, for tests that check what was sent + + def do_POST(self): + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + messages = request["messages"] + Handler.requests.append(messages) + user = next((text_of(m) for m in reversed(messages) if m["role"] == "user"), "") + directive = DIRECTIVE.search(user) + markers = user[:directive.start()] if directive else user + time.sleep(self.delay) + try: + if "FAIL-500" in markers and messages[-1]["role"] == "user": + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"error": {"message": "fake model: scripted failure"}}') + return + body = sse(reply_for(messages), "SSE-CRLF" in markers, + "SSE-UNTERMINATED" in markers) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + self.wfile.write(body.encode()) + except (BrokenPipeError, ConnectionResetError): + pass # the app cancelled the request + + def log_message(self, *args): + pass + + +def serve(delay=1.1): + """Starts the server on a free 127.0.0.1 port in a daemon thread; returns + its /chat/completions URL.""" + Handler.delay = delay + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + server.daemon_threads = True + threading.Thread(target=server.serve_forever, daemon=True).start() + return f"http://127.0.0.1:{server.server_port}/chat/completions" + + +if __name__ == "__main__": + print(serve(float(sys.argv[1]) if len(sys.argv) > 1 else 1.1), flush=True) + threading.Event().wait() diff --git a/scripts/smoke/history.py b/scripts/smoke/history.py new file mode 100644 index 0000000..ea2920c --- /dev/null +++ b/scripts/smoke/history.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Smoke test for project-scoped history search (grit_history_search). + +Usage: + python3 scripts/smoke/history.py build/gritcode + +Seeds a temp data dir with an old-format memory.db (the 0.5.x schema, one +turn from a session that has no file any more) and four threads' session +files, in two folders. Then checks, all against that temp dir: +- `gritcode --mcp-stdio` opens the old DB and finds the old turn; +- `gritcode --reindex` indexes every thread under its own id, with title + and parent, and keeps the old turn; +- the old version's own SQL still works on the result; +- in the app (hidden, fake model), scope "folder" keeps to the folder, + the calling thread is left out and hits carry titles. +The CLI modes run with HOME and XDG_DATA_HOME in the temp dir and no display; +the app runs only through ~/.local/share/grit-dev/run-hidden.sh. +""" + +import json +import os +import random +import socket +import sqlite3 +import subprocess +import sys +import tempfile +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path[:0] = [HERE, os.path.join(HERE, "..")] +from fake_model import serve # noqa: E402 +from grit import Grit # noqa: E402 + +LAUNCHER = os.path.expanduser("~/.local/share/grit-dev/run-hidden.sh") +BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build/gritcode") +DATA = tempfile.mkdtemp(prefix="grit-history-") +PORT = int(os.environ.get("GRIT_MCP_PORT") or random.randint(18700, 18799)) +ROOT = os.path.join(DATA, "gritcode") +DB = os.path.join(ROOT, "memory.db") +failures = 0 + +# The schema and queries 0.5.x uses (git show main:src/memory.cpp). +OLD_SCHEMA = ("CREATE VIRTUAL TABLE IF NOT EXISTS turns USING fts5(cwd, text, " + "role UNINDEXED, session_id UNINDEXED, turn_index UNINDEXED, " + "timestamp UNINDEXED, tokenize = 'porter unicode61')") +OLD_INSERT = ("INSERT INTO turns(cwd, text, role, session_id, turn_index, timestamp) " + "VALUES (?, ?, ?, ?, ?, ?)") +OLD_SEARCH = ("SELECT cwd, role, snippet(turns, 1, '[', ']', '…', 20), session_id, " + "turn_index, timestamp, bm25(turns), length(text) FROM turns " + "WHERE turns MATCH ? ORDER BY bm25(turns) LIMIT ?") + + +def check(cond, what): + global failures + print(("ok " if cond else "FAIL ") + what) + failures += not cond + + +P, Q = os.path.join(DATA, "P"), os.path.join(DATA, "Q") +A, B, C, D = "00000000000000a1", "00000000000000b1", "00000000000000c1", "00000000000000d1" +for d in (P, Q, os.path.join(ROOT, "sessions"), os.path.join(DATA, "home")): + os.makedirs(d) + + +def session(sid, cwd, title, text, parent=""): + j = {"id": sid, "cwd": cwd, "lastUsed": "2026-10-19T10:00:00", "title": title, + "messages": [{"role": "system", "content": "sys"}, + {"role": "user", "content": text}, + {"role": "assistant", "content": "noted"}]} + if parent: + j["parent"] = parent + with open(os.path.join(ROOT, "sessions", sid + ".json"), "w") as f: + json.dump(j, f) + + +session(A, P, "Fix the SSE parser", "zebracorn mentioned in thread A") +session(B, P, "Add dark mode", "zebracorn colours in thread B") +session(C, P, "Look around", "zebracorn found by the subagent", parent=B) +session(D, Q, "Other project", "zebracorn in another folder") + +old = sqlite3.connect(DB) +old.execute(OLD_SCHEMA) +old.execute(OLD_INSERT, (P, "zebracorn from an old version", "user", "0ld0000000000000", 1, + "2025-01-01T00:00:00")) +old.commit() +old.close() + +CLI_ENV = {k: v for k, v in os.environ.items() if k not in ("DISPLAY", "WAYLAND_DISPLAY")} +CLI_ENV.update(HOME=os.path.join(DATA, "home"), XDG_DATA_HOME=DATA) + + +def stdio_search(args): + """One grit_history_search call through `gritcode --mcp-stdio`, run in P.""" + reqs = [{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "grit_history_search", "arguments": args}}] + out = subprocess.run([BIN, "--mcp-stdio"], cwd=P, env=CLI_ENV, timeout=30, + input="".join(json.dumps(r) + "\n" for r in reqs), + capture_output=True, text=True).stdout + return json.loads(out.splitlines()[-1])["result"]["content"][0]["text"] + + +print("-- old-format memory.db") +r = stdio_search({"query": "zebracorn", "scope": "folder"}) +check("0ld0000000000000" in r and "thread:" not in r, "opens and finds the old turn, no title") +tables = {n for (n,) in sqlite3.connect(DB).execute("SELECT name FROM sqlite_master")} +check("threads" in tables, "the threads table was added") + +print("-- reindex") +out = subprocess.run([BIN, "--reindex"], env=CLI_ENV, timeout=60, + capture_output=True, text=True).stdout +check("4 sessions indexed" in out, "every thread's session file is indexed") +db = sqlite3.connect(DB) +sids = {s for (s,) in db.execute("SELECT session_id FROM turns")} +check(sids == {A, B, C, D, "0ld0000000000000"}, "each under its own id, the old turn kept") +rows = dict(((s, (t, p)) for s, t, p in db.execute("SELECT * FROM threads"))) +check(rows.get(C) == ("Look around", B) and rows.get(A) == ("Fix the SSE parser", ""), + "titles and parents recorded") +db.execute(OLD_SCHEMA) +db.execute(OLD_INSERT, (Q, "yakwhistle", "user", "0ld0000000000001", 0, "")) +check(len(db.execute(OLD_SEARCH, ("zebracorn", 20)).fetchall()) == 5 + and len(db.execute(OLD_SEARCH, ("yakwhistle", 20)).fetchall()) == 1, + "0.5.x's own schema statement, insert and search still work") +db.rollback() +db.close() +r = stdio_search({"query": "zebracorn", "scope": "folder", "limit": 20}) +check(D not in r and "thread: Look around (subagent)" in r, "stdio: folder scope and titles") + +print("-- in the app") +env = dict(os.environ, GRIT_TEST_DATA=DATA) +pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", + f"GRIT_FREE_MODEL_URL={serve(0.2)}"], + env=env, stderr=subprocess.DEVNULL)) +try: + # Wait for this instance's own port (Grit would also try the next ones). + for _ in range(100): + try: + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + break + except OSError: + time.sleep(0.2) + g = Grit(port=PORT) + g.select_thread(A) + g.send("Call these tools now: " + json.dumps([ + ["grit_history_search", {"query": "zebracorn", "scope": "folder", "limit": 20}], + ["grit_history_search", {"query": "zebracorn", "limit": 20}]])) + end = time.time() + 60 + while time.time() < end and (time.sleep(0.5) or any( + t["running"] for t in g.list_threads()["threads"])): + pass + results = [m["content"] for m in g.conversation() if m["role"] == "tool"] + check(len(results) == 2, "both searches ran") + folder, everywhere = (results + ["", ""])[:2] + check(B in folder and C in folder and "0ld0000000000000" in folder, + "folder scope finds the folder's other threads and old turns") + check(D not in folder, "folder scope leaves out the other folder") + check(A not in folder and A not in everywhere, "the calling thread is left out") + check("thread: Add dark mode\n" in folder and "thread: Look around (subagent)" in folder, + "hits carry the thread title and mark subagents") + check(D in everywhere and "thread: Other project" in everywhere, + "default scope searches every folder") + time.sleep(1) # the save runs on a worker after the turn + db = sqlite3.connect(DB) + saved = db.execute("SELECT count(*) FROM turns WHERE session_id = ? AND text LIKE " + "'Call these tools now%'", (A,)).fetchone()[0] + check(saved == 1 and db.execute("SELECT title FROM threads WHERE session_id = ?", + (A,)).fetchone() == ("Fix the SSE parser",), + "the app indexes the thread's turn under its own id, with its title") + db.close() +finally: + os.kill(pid, 15) + +print("all ok" if not failures else f"{failures} FAILED") +sys.exit(1 if failures else 0) diff --git a/scripts/smoke/play.py b/scripts/smoke/play.py new file mode 100644 index 0000000..61c3887 --- /dev/null +++ b/scripts/smoke/play.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Smoke test for Play runs and drafts across threads: one Play run per folder, +its output kept with the thread that pressed Play, and the input draft kept +per thread. + +Usage: + python3 scripts/smoke/play.py build/gritcode + +Runs the app only through ~/.local/share/grit-dev/run-hidden.sh (invisible +Xvfb, temp HOME and data dir). The Play commands are silent shell commands +(sleep, echo) whose output differs from the command text. +""" + +import json +import os +import random +import shutil +import socket +import subprocess +import sys +import tempfile +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +from grit import Grit # noqa: E402 +from fake_model import serve # noqa: E402 + +LAUNCHER = os.path.expanduser("~/.local/share/grit-dev/run-hidden.sh") +BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build/gritcode") +DATA = tempfile.mkdtemp(prefix="grit-play-") +PORT = int(os.environ.get("GRIT_MCP_PORT") or random.randint(18900, 18949)) +failures = 0 + + +def check(cond, what): + global failures + print(("ok " if cond else "FAIL ") + what) + failures += not cond + + +def previews(g): + return " ".join(b["preview"] for b in g.blocks()) + + +P, Q = os.path.join(DATA, "P"), os.path.join(DATA, "Q") +os.makedirs(P) +os.makedirs(Q) +os.makedirs(os.path.join(DATA, "gritcode")) +with open(os.path.join(DATA, "gritcode", "run_configs.json"), "w") as f: + json.dump({P: {"command": "sleep 4; echo P-$((40+2))", "discoveredBy": "user", "lastUsed": ""}, + Q: {"command": "sleep 1; echo Q-$((40+3))", "discoveredBy": "user", "lastUsed": ""}}, f) + +env = dict(os.environ, GRIT_TEST_DATA=DATA) +pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", + f"GRIT_FREE_MODEL_URL={serve()}"], + env=env, stderr=subprocess.DEVNULL)) +try: + for _ in range(100): + try: + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + break + except OSError: + time.sleep(0.2) + g = Grit(port=PORT) + + print("-- Play in two folders") + a = g.switch_session(P)["threadId"] + g._call("setInput", {"text": "draft for P"}) + check(g._call("play").get("started"), "Play starts in P") + q = g.switch_session(Q)["threadId"] + check(g.status()["input"] == "", "P's draft doesn't follow to Q") + check(g._call("play").get("started"), "Play starts in Q while P's run goes on") + time.sleep(6) + p = previews(g) + check("Q-43" in p and "P-42" not in p, "Q's output is in Q's thread only") + + print("-- back in P") + g.switch_session(P) + check(g.status()["input"] == "draft for P", "P's draft is back") + p = previews(g) + check("Build and run" in p and "P-42" in p, "P's run finished, not stopped by Q's, and shows in P") + check("Q-43" not in p, "Q's output isn't in P") + + print("-- threads of one folder") + b = g.new_thread(P)["threadId"] + check(g.status()["input"] == "" and "P-42" not in previews(g), + "a new thread starts with an empty input and no Play output") + g._call("setInput", {"text": "draft for b"}) + g.select_thread(a) + check(g.status()["input"] == "draft for P", "each thread keeps its own draft") + g.select_thread(b) + check(g.status()["input"] == "draft for b", "and gets it back") + g.switch_session(Q) + check("Q-43" in previews(g), "Q's output is still there after switching away and back") +finally: + os.kill(pid, 15) + +print(f"{failures} failure(s); data in {DATA}" if failures else "all ok") +if not failures: + shutil.rmtree(DATA, ignore_errors=True) +sys.exit(1 if failures else 0) diff --git a/scripts/smoke/subagents.py b/scripts/smoke/subagents.py new file mode 100644 index 0000000..2eef3d3 --- /dev/null +++ b/scripts/smoke/subagents.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +""" +Smoke test for subagents: drives a hidden gritcode over the control API. + +Usage: + python3 scripts/smoke/subagents.py [--live] build/gritcode + +Runs the app only through ~/.local/share/grit-dev/run-hidden.sh (invisible +Xvfb, temp HOME and data dir). Claude Code subagents are checked against a +fake `claude` that replays a few events in the shape the real CLI sends. The +`task` tool is checked on the free model (Kilo Free) pointed at a local fake +(fake_model.py). With --live it uses the real Kilo Free, which is flaky, so +those results are informational, plus DeepSeek when an API key is set. +""" + +import json +import os +import random +import shutil +import socket +import stat +import subprocess +import sys +import tempfile +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path[:0] = [HERE, os.path.join(HERE, "..")] +from fake_model import serve # noqa: E402 +from grit import Grit # noqa: E402 + +LAUNCHER = os.path.expanduser("~/.local/share/grit-dev/run-hidden.sh") +STUBS = os.path.expanduser("~/.local/share/grit-dev/stubs") +LIVE = "--live" in sys.argv +ARGS = [a for a in sys.argv[1:] if a != "--live"] +BIN = os.path.abspath(ARGS[0] if ARGS else "build/gritcode") +MODEL_ENV = [] if LIVE else [f"GRIT_FREE_MODEL_URL={serve()}"] +DATA = tempfile.mkdtemp(prefix="grit-subagents-") +FAKE = os.path.join(DATA, "fakebin") +PORT = random.randint(18900, 18999) +CLAUDE_ROW = 3 # Kilo Free, two DeepSeek fallback models, then Claude +failures = 0 + +FAKE_CLAUDE = """#!/usr/bin/env python3 +import json, os, sys, time +sys.stdin.readline() +with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "events.jsonl")) as f: + for line in f: + ev = json.loads(line) + if "sleep" in ev: + time.sleep(ev["sleep"]) + continue + print(json.dumps(ev), flush=True) + time.sleep(0.05) +""" + + +def launch(): + env = dict(os.environ, GRIT_TEST_DATA=DATA) + path = f"PATH={FAKE}:{STUBS}:{os.environ['PATH']}" + out = subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", path, *MODEL_ENV], + env=env, stderr=subprocess.DEVNULL) + # Wait for this instance's own port before sending anything. + for _ in range(100): + try: + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + return int(out), Grit(port=PORT) + except OSError: + time.sleep(0.2) + sys.exit("gritcode did not start") + + +def stop(pid): + os.kill(pid, 15) + for _ in range(100): + try: + os.kill(pid, 0) + time.sleep(0.1) + except ProcessLookupError: + return + + +def check(cond, what): + global failures + print(("ok " if cond else "FAIL ") + what) + failures += not cond + + +def info(cond, what): + print(("ok " if cond else "info not: ") + what) + + +def threads(g): + return {t["id"]: t for t in g.list_threads()["threads"]} + + +def children(g, parent): + return [t for t in threads(g).values() if t["parent"] == parent] + + +def wait_idle(g, timeout=60): + end = time.time() + timeout + while time.time() < end: + if not any(t["running"] for t in threads(g).values()): + return True + time.sleep(0.5) + return False + + +def wait_for(cond, timeout=20): + end = time.time() + timeout + while time.time() < end: + if cond(): + return True + time.sleep(0.2) + return False + + +def text(g): + return json.dumps(g.conversation()) + + +def blocks(g): + return " | ".join(b["preview"] for b in g.blocks()) + + +def tool_results(g, name): + return [m["content"] for m in g.conversation() if m["role"] == "tool" and m.get("name") == name] + + +# ---- prompts the fake model (fake_model.py) acts on ---- + +def bash(cmd): + return f"Call the bash tool now with exactly this command: {cmd}" + + +def call_tools(*calls): + return "Call these tools now: " + json.dumps(calls) + + +def task(description, prompt): + return ("task", {"description": description, "prompt": prompt}) + + +# ---- fake Claude Code events ---- + +def assistant(msg_id, block, parent=None): + return {"type": "assistant", "parent_tool_use_id": parent, + "message": {"id": msg_id, "role": "assistant", "content": [block]}} + + +def tool_use(tool_id, name, inp): + return {"type": "tool_use", "id": tool_id, "name": name, "input": inp} + + +def tool_result(tool_id, content, parent=None, background=False): + ev = {"type": "user", "parent_tool_use_id": parent, + "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": tool_id, "content": content}]}} + if background: + ev["tool_use_result"] = {"isAsync": True, "status": "async_launched"} + return ev + + +INIT = {"type": "system", "subtype": "init", "session_id": "fake-1", "model": "claude-opus-5-5"} +RESULT = {"type": "result", "subtype": "success", "is_error": False, + "result": "done", "session_id": "fake-1"} + + +def scenario(events): + with open(os.path.join(FAKE, "events.jsonl"), "w") as f: + f.writelines(json.dumps(e) + "\n" for e in events) + + +os.makedirs(FAKE) +with open(os.path.join(FAKE, "claude"), "w") as f: + f.write(FAKE_CLAUDE) +os.chmod(os.path.join(FAKE, "claude"), stat.S_IRWXU) +A = os.path.join(DATA, "A") +os.makedirs(A) +pid, g = launch() +top = g.switch_session(A)["threadId"] +check(g.set_model(CLAUDE_ROW).get("modelIndex") == 100, "Claude model selected") + +print("-- Claude Code subagents") +scenario([ + INIT, + assistant("m1", {"type": "text", "text": "Delegating."}), + assistant("m1", tool_use("A1", "Agent", {"description": "Count files", + "prompt": "List the files and count them.", + "subagent_type": "general-purpose"})), + tool_result("A1", "Async agent launched successfully.", background=True), + assistant("m2", tool_use("B1", "Bash", {"command": "ls"}), parent="A1"), + tool_result("B1", "a.txt\nb.txt", parent="A1"), + assistant("m3", {"type": "text", "text": "SUB-REPORT: 2 files."}, parent="A1"), + {"type": "system", "subtype": "task_notification", "tool_use_id": "A1", + "status": "completed"}, + # An older CLI: the Task call's result arrives when the subagent is done. + assistant("m4", tool_use("A2", "Task", {"description": "Old style task", + "prompt": "Say hi."})), + assistant("m5", {"type": "text", "text": "OLD-REPORT hi"}, parent="A2"), + tool_result("A2", "OLD-REPORT hi"), + assistant("m6", {"type": "text", "text": "PARENT-DONE"}), + RESULT, +]) +g.send("Count the files with a subagent.") +check(wait_idle(g), "the turn finished") +kids = {t["title"]: t for t in children(g, top)} +check(set(kids) == {"Count files", "Old style task"}, "two child threads under the parent") +check(not any(t["running"] or t["failed"] for t in kids.values()), "both done, none failed") +parent = text(g) +check("PARENT-DONE" in parent and "SUB-REPORT" not in parent, + "the parent has its own messages only") +check(kids.get("Count files", {}).get("id", "?") in parent, "the Agent call names its thread") +check("Subagent thread: Count files" in blocks(g), "the parent links to the child") +g.select_thread(kids["Count files"]["id"]) +child = text(g) +check("List the files" in child and "a.txt" in child and "SUB-REPORT" in child, + "the child has the prompt, its tool call and its report") +check("Subagent thread (view only)" in blocks(g), "the child says it is view only") +try: + g.send("hello") + check(False, "sending to a child is refused") +except RuntimeError: + check(True, "sending to a child is refused") +g.select_thread(kids["Old style task"]["id"]) +check("OLD-REPORT" in text(g), "an older CLI's Task subagent is shown too") + +print("-- cancelling") +slow = [ + INIT, + assistant("m1", tool_use("A1", "Agent", {"description": "Slow task", "prompt": "Wait."})), + tool_result("A1", "Async agent launched successfully.", background=True), + assistant("m2", {"type": "text", "text": "working"}, parent="A1"), + {"sleep": 60}, + RESULT, +] +for view in ("parent", "child"): + scenario(slow) + p = g.new_thread(A)["threadId"] + g.send("Start a slow subagent.") + check(wait_for(lambda: any(t["running"] for t in children(g, p))), + f"[{view}] the child runs") + c = children(g, p)[0]["id"] + if view == "child": + g.select_thread(c) + g.cancel() + check(wait_idle(g), f"[{view}] cancel stops the parent and the child") + if view == "parent": + check(threads(g)[c]["failed"], "[parent] the child is marked as failed") + check("Cancelled." in blocks(g), "[parent] the parent says it was cancelled") + g.select_thread(c) + check("was stopped" in blocks(g), f"[{view}] the child says it was stopped") + g.select_thread(p) + check("was stopped" in blocks(g), f"[{view}] so does the parent's Agent call") + +print("-- restart") +stop(pid) +pid, g = launch() +kids = {t["title"]: t for t in children(g, top)} +check(set(kids) == {"Count files", "Old style task"}, "child threads restored with their parent") +g.select_thread(top) +check("Subagent thread: Count files" in blocks(g), "the restored parent links to the child") + +if LIVE: + print("-- task tool (live, informational)") + models = [(0, "Kilo Free")] + if g.preferences().get("hasDeepseekKey"): + models.append((2, "DeepSeek")) + else: + print("skip DeepSeek subagents (no API key)") + for row, name in models: + g.set_model(row) + p = g.new_thread(A)["threadId"] + g.send("Call the task tool twice in one response, so both run in parallel: " + "first with description 'Echo one' and prompt 'Run the bash command " + "echo SUB-ONE and report its output.', then with description 'Echo two' " + "and prompt 'Run the bash command echo SUB-TWO and report its output.' " + "Then tell me what they reported.") + info(wait_idle(g, 300), f"[{name}] the turn finished") + kids = children(g, p) + info(len(kids) == 2, f"[{name}] two child threads") + tools = [m for m in g.conversation() if m["role"] == "tool" and m.get("name") == "task"] + info([("SUB-ONE" in t["content"], "SUB-TWO" in t["content"]) for t in tools] + == [(True, False), (False, True)], f"[{name}] reports in call order") +else: + print("-- task tool") + g.set_model(0) + p = g.new_thread(A)["threadId"] + g.send(call_tools(task("Echo one", bash("sleep 3; echo SUB-ONE")), + task("Echo two", bash("echo SUB-TWO")))) + check(wait_for(lambda: sum(t["running"] for t in children(g, p)) == 2), + "two task calls in one batch run at once") + check(wait_idle(g), "the turn finished") + check(sorted(t["title"] for t in children(g, p)) == ["Echo one", "Echo two"], + "two child threads") + reports = tool_results(g, "task") + check([("SUB-ONE" in r, "SUB-TWO" in r) for r in reports] == [(True, False), (False, True)], + "reports in call order, though the second finished first") + last = g.conversation()[-1] + check(last["role"] == "assistant" and "SUB-ONE" in last["content"], + "the parent continues with the reports") + + p = g.new_thread(A)["threadId"] + g.send(call_tools(task("Fails", "FAIL-500"))) + check(wait_idle(g), "[failing child] the turn finished") + check([t["failed"] for t in children(g, p)] == [True], "[failing child] the child failed") + reports = tool_results(g, "task") + check(len(reports) == 1 and reports[0].startswith("Error: the subagent failed") + and "HTTP 500" in reports[0], "[failing child] the parent gets an error result") + + p = g.new_thread(A)["threadId"] + g.send(call_tools(task("Writes", call_tools(("write_file", {"path": "w.txt", "content": "x"}))))) + check(wait_idle(g), "[write_file] the turn finished") + check(not os.path.exists(os.path.join(A, "w.txt")), "[write_file] the child wrote nothing") + for t in children(g, p): + g.select_thread(t["id"]) + refused = tool_results(g, "write_file") + check(len(refused) == 1 and "read-only" in refused[0], "[write_file] the child's call is refused") + + p = g.new_thread(A)["threadId"] + g.send(call_tools(task("Sleeps", bash("sleep 5; touch late.txt")))) + check(wait_for(lambda: any(t["running"] for t in children(g, p))), "[cancel] the child runs") + time.sleep(2) # into the child's bash call + g.cancel() + check(wait_idle(g, 5), "[cancel] cancelling the parent stops the child") + check([t["failed"] for t in children(g, p)] == [True], "[cancel] the child is marked as failed") + time.sleep(5) + check(not os.path.exists(os.path.join(A, "late.txt")), "[cancel] the child's bash was killed") +stop(pid) + +print(f"{failures} failure(s); data in {DATA}" if failures else "all ok") +if not failures: + shutil.rmtree(DATA, ignore_errors=True) +sys.exit(1 if failures else 0) diff --git a/scripts/smoke/thread_pane.py b/scripts/smoke/thread_pane.py new file mode 100644 index 0000000..bc67ec3 --- /dev/null +++ b/scripts/smoke/thread_pane.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Smoke test for the left pane's Threads view and the folder-level session +dropdown: rows, the Session | Threads switch and the remembered view. + +Usage: + python3 scripts/smoke/thread_pane.py build/gritcode + +Runs the app only through ~/.local/share/grit-dev/run-hidden.sh (invisible +Xvfb, temp HOME and data dir). The folder's threads are seeded into the index +before launch; the live states come from turns against a local fake model +(fake_model.py) that are cancelled right away. +""" + +import json +import os +import random +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from datetime import datetime + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +from grit import Grit # noqa: E402 +from fake_model import serve # noqa: E402 + +LAUNCHER = os.path.expanduser("~/.local/share/grit-dev/run-hidden.sh") +BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build/gritcode") +DATA = tempfile.mkdtemp(prefix="grit-pane-") +PORT = int(os.environ.get("GRIT_MCP_PORT") or random.randint(18800, 18899)) +CONF = os.path.join(DATA, "home", ".gritcode", "gritcode.conf") +failures = 0 + + +def check(cond, what): + global failures + print(("ok " if cond else "FAIL ") + what) + failures += not cond + + +def panes(g): + return g._call("getPanes") + + +def rows(g): + return {r["id"]: r for r in panes(g)["threadRows"]} + + +def day(iso): + d = datetime.fromisoformat(iso) + return d.strftime("%b %d") if d.year == datetime.now().year else d.date().isoformat() + + +P, Q = os.path.join(DATA, "P"), os.path.join(DATA, "Q") +os.makedirs(P) +os.makedirs(Q) +os.makedirs(os.path.join(DATA, "gritcode")) +with open(os.path.join(DATA, "gritcode", "sessions.json"), "w") as f: + json.dump({"sessions": [{"cwd": P, "lastUsed": "2026-10-18T10:00:00", + "title": "Fix the SSE parser"}, + {"cwd": Q, "lastUsed": "2026-10-17T10:00:00", + "title": "Other folder"}], + "threads": [{"id": "00000000000000aa", "cwd": P, + "lastUsed": "2025-10-19T09:00:00", + "title": "Add dark mode"}, + {"id": "00000000000000ab", "cwd": P, + "lastUsed": "2026-10-19T09:30:00", + "title": "Read the theme code", + "parent": "00000000000000aa"}], + "lastActiveCwd": P}, f) + +env = dict(os.environ, GRIT_TEST_DATA=DATA) +pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", + f"GRIT_FREE_MODEL_URL={serve()}"], + env=env, stderr=subprocess.DEVNULL)) +try: + for _ in range(100): + try: + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + break + except OSError: + time.sleep(0.2) + g = Grit(port=PORT) + + print("-- session dropdown") + p = panes(g) + check(p["threads"] == 0 and p["import"] == 0, "the left pane starts closed") + s = p["sessions"] + check(len(s) == 3 and s[0].startswith("New Session") and s[1].endswith("/P") + and s[2].endswith("/Q"), f"it lists the folders, not threads ({s})") + + print("-- rows") + g._call("togglePanel", {"panel": "threads"}) + p = panes(g) + check(p["threads"] > 0, "the left pane opens on Threads") + order = [r["id"] for r in p["threadRows"]] + first = order[0] if order else "" + check(order[1:] == ["00000000000000aa", "00000000000000ab"], + "most recent first, the subagent under its parent") + r = rows(g) + check(r["00000000000000ab"]["child"] and not r["00000000000000aa"]["child"], + "only the subagent is nested") + check(r[first]["detail"] == day("2026-10-18T10:00:00") + and r["00000000000000aa"]["detail"] == day("2025-10-19T09:00:00"), + "idle rows say when they were last used") + check(len(order) == 3, "other folders' threads are not listed") + + print("-- live state") + a = g.new_thread(P)["threadId"] + check(next(iter(rows(g))) == a, "a new thread is listed first") + g.send("Say hello.") + time.sleep(0.3) + step = rows(g)[a]["detail"] + check(step in ("thinking", "writing"), f"a running row shows its step ({step})") + b = g.new_thread(P)["threadId"] + g.send("Say bye.") + time.sleep(0.3) + step = rows(g)[b]["detail"] + check(step in ("thinking", "writing"), f"a second thread in the folder runs at once ({step})") + g.cancel() + g.select_thread(a) + g.cancel() + g.wait_idle(timeout=30, poll=0.2) + detail = rows(g)[a]["detail"] + check(":" in detail, f"a stopped row shows its time again ({detail})") + + print("-- marks") + + def mark(): # the folder's mark in the session dropdown + label = next(x for x in panes(g)["sessions"] if x.endswith("/P")) + return label[:2] if label[:1] in ("\u25cf", "\u26a0") else "" + + def wait_done(tid): + end = time.time() + 30 + while time.time() < end and any( + t["id"] == tid and t["running"] for t in g.list_threads()["threads"]): + time.sleep(0.2) + + g.select_thread(a) + g.send("Say hi.") + time.sleep(0.3) + check(mark() == "", "the thread on screen working alone sets no mark") + g.select_thread(b) + check(mark() == "\u25cf ", "another thread working marks the folder") + wait_done(a) + check(mark() == "\u25cf " and rows(g)[a]["state"] == "unseen", + "one that finished while not shown keeps the mark, in the list too") + g.select_thread(a) + check(mark() == "" and rows(g)[a]["state"] == "done", "viewing it clears the mark") + g.select_thread(b) + g.send("FAIL-500 oops") + g.select_thread(a) # before the fake model's reply delay ends + wait_done(b) + check(mark() == "\u26a0 " and rows(g)[b]["state"] == "failed", "an unseen failure shows \u26a0") + g.select_thread(b) + check(mark() == "", "viewing it clears that too") + + print("-- switch") + before = panes(g) + g._call("togglePanel", {"panel": "import"}) + p = panes(g) + check(p["threads"] == 0 and p["import"] == before["import"] > 0, + "Session swaps the view in the same pane") + check(p["window"] == before["window"], "the window keeps its width") + g._call("togglePanel", {"panel": "threads"}) + check(panes(g)["threads"] > 0 and len(rows(g)) >= 5, "Threads shows it again, filled") + check("LeftPaneThreads=1" in open(CONF).read(), "the view is remembered") + g._call("togglePanel", {"panel": "threads"}) + time.sleep(0.5) + after = panes(g) + check(after["import"] == 0 and after["chat"] == before["chat"] + and after["window"] < before["window"], "closing keeps the chat width") + g._call("togglePanel", {"panel": "threads"}) + + print("-- another folder") + q = g.switch_session(Q)["threadId"] + check(list(rows(g)) == [q], "the pane follows the active folder") +finally: + os.kill(pid, 15) + +print(f"{failures} failure(s); data in {DATA}" if failures else "all ok") +if not failures: + shutil.rmtree(DATA, ignore_errors=True) +sys.exit(1 if failures else 0) diff --git a/scripts/smoke/threads.py b/scripts/smoke/threads.py new file mode 100755 index 0000000..e08d2de --- /dev/null +++ b/scripts/smoke/threads.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +""" +Smoke test for threads: drives a hidden gritcode over the control API. + +Usage: + python3 scripts/smoke/threads.py [--live] build/gritcode + +Runs the app only through ~/.local/share/grit-dev/run-hidden.sh (invisible +Xvfb, temp HOME and data dir). Turns use the free model (Kilo Free), pointed +at a local fake (fake_model.py) unless --live: the real one is flaky, so a +live run can fail on model errors rather than on gritcode. +""" + +import json +import os +import random +import shutil +import socket +import subprocess +import sys +import tempfile +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path[:0] = [HERE, os.path.join(HERE, "..")] +from fake_model import Handler, serve # noqa: E402 +from grit import Grit # noqa: E402 + +LAUNCHER = os.path.expanduser("~/.local/share/grit-dev/run-hidden.sh") +LIVE = "--live" in sys.argv +ARGS = [a for a in sys.argv[1:] if a != "--live"] +BIN = os.path.abspath(ARGS[0] if ARGS else "build/gritcode") +MODEL_ENV = [] if LIVE else [f"GRIT_FREE_MODEL_URL={serve()}"] +DATA = tempfile.mkdtemp(prefix="grit-threads-") +PORT = int(os.environ.get("GRIT_MCP_PORT") or random.randint(18800, 18899)) +INDEX = os.path.join(DATA, "gritcode", "sessions.json") +SESSIONS = os.path.join(DATA, "gritcode", "sessions") +failures = 0 + + +def launch(): + env = dict(os.environ, GRIT_TEST_DATA=DATA) + out = subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", *MODEL_ENV], + env=env, stderr=subprocess.DEVNULL) + # Wait for this instance's own port before sending anything. + for _ in range(100): + try: + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + return int(out), Grit(port=PORT) + except OSError: + time.sleep(0.2) + sys.exit("gritcode did not start") + + +def stop(pid): + os.kill(pid, 15) + for _ in range(100): + try: + os.kill(pid, 0) + time.sleep(0.1) + except ProcessLookupError: + return + + +def check(cond, what): + global failures + print(("ok " if cond else "FAIL ") + what) + failures += not cond + + +def threads(g): + return {t["id"]: t for t in g.list_threads()["threads"]} + + +def wait_idle(g, timeout=240): + end = time.time() + timeout + while time.time() < end: + if not any(t["running"] for t in threads(g).values()): + return True + time.sleep(1) + return False + + +def history(g, thread_id): + g.select_thread(thread_id) + return json.dumps(g.conversation()) + + +def read(path): + try: + with open(path) as f: + return f.read().strip() + except OSError: + return "" + + +def bash(cmd): + return f"Call the bash tool now with exactly this command: {cmd}" + + +A, B = os.path.join(DATA, "A"), os.path.join(DATA, "B") +os.makedirs(A) +os.makedirs(B) +subprocess.run(["git", "init", "-q", B], check=True) # B's threads share a git checkout +pid, g = launch() +g.set_model(0) + +print("-- two threads in different folders at once") +a = g.switch_session(A)["threadId"] +g.send(bash("sleep 3; pwd > ran_here.txt; echo marker-A")) +b = g.switch_session(B)["threadId"] +g.send(bash("sleep 3; pwd > ran_here.txt; echo marker-B")) +now = threads(g) +check(now[a]["running"] and now[b]["running"], "both threads run at the same time") +check(wait_idle(g), "both turns finished") +check(read(os.path.join(A, "ran_here.txt")) == A, "A's bash ran in A") +check(read(os.path.join(B, "ran_here.txt")) == B, "B's bash ran in B") +ha, hb = history(g, a), history(g, b) +check("marker-A" in ha and "marker-B" not in ha, "A's history has only A's messages") +check("marker-B" in hb and "marker-A" not in hb, "B's history has only B's messages") + +print("-- two threads in the same folder") +g.select_thread(b) +g.send(bash("sleep 6; date +%s.%N > first_done")) +time.sleep(1) +b2 = g.new_thread(B)["threadId"] +g.send(bash("date +%s.%N > second_start")) +now = threads(g) +check(now[b]["running"] and now[b2]["running"], "both threads run at once") +check(not any("Also working in" in blk["preview"] for blk in g.blocks()), + "the chat doesn't show the note") + + +def request_with(text): + end = time.time() + 10 + while time.time() < end: + found = [m for m in Handler.requests if text in json.dumps(m[-1])] + if found: + return found[0] + time.sleep(0.2) + return None + + +if not LIVE: + sent = request_with("second_start") + check(sent is not None and f"[gritcode: Also working in {B} right now:" in sent[-1]["content"], + "the model is told the first thread is working there") + check(sent is not None and "git worktree add -b" in sent[0]["content"], + "the system prompt of a thread in a git repo says when to use a worktree") + end = time.time() + 30 + while time.time() < end and threads(g)[b2]["running"]: + time.sleep(0.2) + g.send("again-marker") # the first thread is still running: nothing new + sent = request_with("again-marker") + check(sent is not None and "[gritcode:" not in sent[-1]["content"], "an unchanged note isn't repeated") +check(wait_idle(g), "both turns finished") +first, second = read(os.path.join(B, "first_done")), read(os.path.join(B, "second_start")) +check(bool(first and second) and float(second) < float(first), + "the second ran while the first was still going") +if not LIVE: + g.send("later-marker") + sent = request_with("later-marker") + check(sent is not None and f"[gritcode: No other thread is working in {B} now.]" + in sent[-1]["content"], "the model is told when the other thread stopped") + check(wait_idle(g), "that turn finished") + +print("-- opening a folder") +g.switch_session(A) +latest = max((t for t in threads(g).values() if t["cwd"] == B), key=lambda t: t["lastUsed"]) +check(g.switch_session(B).get("threadId") == latest["id"], "it shows the folder's most recent thread") + +if not LIVE: + print("-- SSE framing") + g.select_thread(a) + for marker in ("SSE-CRLF", "SSE-UNTERMINATED"): + g.send(f"{marker} " + bash(f"echo {marker} > framing.txt")) + check(wait_idle(g), f"[{marker}] the turn finished") + check(read(os.path.join(A, "framing.txt")) == marker, f"[{marker}] the bash call ran") + last = g.conversation()[-1] + check(last["role"] == "assistant" and last["content"].startswith("Results:"), + f"[{marker}] the reply after it arrived") + +x = None +if not LIVE: + print("-- a turn that fails in the background") + x = g.new_thread(B)["threadId"] + g.send("FAIL-500 remember this prompt") # the fake answers 500 after its delay + g.select_thread(b) # so it fails while another thread is shown + end = time.time() + 60 + while time.time() < end and threads(g)[x]["running"]: + time.sleep(0.5) + now = threads(g) + check(now[x]["failed"] and not now[x]["running"], "it failed in the background") + g.select_thread(x) + check(any("scripted failure" in blk["preview"] for blk in g.blocks()), + "its notice shows when viewed") + check(g.status()["input"] == "FAIL-500 remember this prompt", "its prompt is back in the input") + check(not threads(g)[x]["failed"], "it is no longer marked as failed") + +print("-- restart") +stop(pid) +pid, g = launch() +now = threads(g) +check({a, b, b2} | ({x} if x else set()) <= now.keys(), "threads restored") +check(g.status()["threadId"] == (x or latest["id"]), "last active thread restored") + +check(now[b2]["title"].startswith("Call the bash tool"), "titles restored") + +print("-- index rewritten by 0.5.x (no threads array)") +stop(pid) +with open(INDEX) as f: + index = json.load(f) +old = {"sessions": [{k: e[k] for k in ("id", "cwd", "lastUsed")} for e in index["sessions"]], + "lastActiveCwd": index["lastActiveCwd"]} +with open(INDEX, "w") as f: + json.dump(old, f, indent=2) +with open(INDEX, "rb") as f: + before = f.read() +pid, g = launch() +now = threads(g) +check(b2 in now and now[b2]["cwd"] == B, "threads missing from the index are listed again") +check(g.status()["threadId"] == b, "opens the last folder's first thread") +with open(INDEX, "rb") as f: + check(f.read() == before, "index not rewritten at startup") + +print("-- old-format data (one session per folder)") +stop(pid) +first_ids = {e["id"] for e in old["sessions"]} +for name in os.listdir(SESSIONS): + if name.removesuffix(".json") not in first_ids: + os.remove(os.path.join(SESSIONS, name)) +pid, g = launch() +check(threads(g).keys() == first_ids, "each folder's session is its first thread") +check(g.status()["historyLen"] > 1, "its history loaded") +with open(INDEX, "rb") as f: + check(f.read() == before, "nothing rewritten") +stop(pid) + +print(f"{failures} failure(s); data in {DATA}" if failures else "all ok") +if not failures: + shutil.rmtree(DATA, ignore_errors=True) +sys.exit(1 if failures else 0) diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 6d859d7..362c361 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -8,6 +8,7 @@ #include "preferences.h" #include "run_config_store.h" #include "settings_dialog.h" +#include "project_brief.h" #include "image_store.h" #include "debug_window.h" #include "syntax.h" @@ -56,17 +57,8 @@ #include #ifndef _WIN32 #include -#else -#include -#define chdir _chdir #endif -// Posted from the tool-dispatch worker thread when a batch completes. -// Payload is a shared_ptr> — shared_ptr so wxThreadEvent's -// payload copy doesn't slice the move-only vector and the worker can hand off -// ownership cheaply. -wxDEFINE_EVENT(wxEVT_TOOL_BATCH_DONE, wxThreadEvent); - namespace { constexpr int ID_SEND = wxID_HIGHEST + 1; @@ -84,7 +76,7 @@ constexpr int ID_TREE_NEW_FILE = wxID_HIGHEST + 17; constexpr int ID_TREE_RENAME = wxID_HIGHEST + 18; constexpr int ID_TREE_SHOW_IN_FILES = wxID_HIGHEST + 19; constexpr int ID_TREE_NEW_FOLDER = wxID_HIGHEST + 20; -constexpr int ID_TREE_REFRESH = wxID_HIGHEST + 21; +constexpr int ID_TREE_REFRESH = wxID_HIGHEST + 28; constexpr int ID_TREE_TOGGLE_HIDDEN = wxID_HIGHEST + 27; constexpr int ID_EDITOR_SAVE = wxID_HIGHEST + 21; constexpr int ID_EDITOR_SAVE_AS = wxID_HIGHEST + 22; @@ -101,8 +93,10 @@ constexpr int kFileTreeWidth = 280; // fixed (non-resizable) file tree widt constexpr int kEditorTextWidth = kFileTreeWidth * 5 / 2; // editor text = 2.5x tree constexpr int kEditorPaneWidth = kFileTreeWidth + kEditorTextWidth; // whole right pane constexpr int kMainMinClientW = 220; // min chat-pane width with no panels +// The thread pane sits left of the splitters in the frame sizer, at a fixed +// width (no sash), so the window grows by exactly this much. // Chat-pane widths below which toolbar items hide, least important first. -constexpr int kToolbarLabelsW = 640; // "Session:" / "Model:" captions +constexpr int kToolbarLabelsW = 640; // "Thread:" / "Model:" captions constexpr int kToolbarSettingsW = 500; // settings (and debug Log) button constexpr int kToolbarExportW = 466; // export button constexpr int kToolbarModelW = 430; // model dropdown @@ -363,6 +357,14 @@ int ModelIndexForRow(int row, const std::vector& remoteModels) { return kClaudeIndexBase + (row - 1 - dsCount); } +// The free model's endpoint. GRIT_FREE_MODEL_URL overrides it: a test/dev +// hook that lets the smoke tests use a local fake model +// (scripts/smoke/fake_model.py) instead of the flaky shared free tier. +const char* FreeModelUrl() { + const char* url = std::getenv("GRIT_FREE_MODEL_URL"); + return url && *url ? url : "https://api.kilo.ai/api/gateway/chat/completions"; +} + ModelRoute RouteForIndex(int idx, const std::vector& remoteModels) { if (idx == kModelKiloFree) { // Kilo Gateway free tier: kilo-auto/free auto-routes to whichever @@ -370,7 +372,7 @@ ModelRoute RouteForIndex(int idx, const std::vector& remoteModels) // 256K context, anonymous rate limit 200 requests/hour/IP. Free // models are shared-pool and best-effort, so this is the no-key // default, not a replacement for a DeepSeek key. - return {"https://api.kilo.ai/api/gateway/chat/completions", + return {FreeModelUrl(), "kilo-auto/free", false, Preferences::Provider::DeepSeek, kOutputTokenMax, 256000}; } @@ -394,7 +396,7 @@ ModelRoute RouteForIndex(int idx, const std::vector& remoteModels) } // Unknown/stale index — fall back to the no-key free provider. - return {"https://api.kilo.ai/api/gateway/chat/completions", + return {FreeModelUrl(), "kilo-auto/free", false, Preferences::Provider::DeepSeek, 32000, 256000}; } @@ -429,6 +431,48 @@ ModelRoute CompletionRoute(int currentIdx, int turnIdx, return route; } +// Model index for a subagent started by a turn on `parentModel`: a DeepSeek +// Pro model's matching Flash model when listed (a fraction of the price), +// otherwise the parent's own model (`parentIdx` when it isn't listed). +int SubagentModelIndex(const std::string& parentModel, int parentIdx, + const std::vector& remoteModels) { + const auto& ds = DeepseekModels(remoteModels); + auto indexOf = [&ds](const std::string& id) { + auto it = std::ranges::find(ds, id); + return it == ds.end() ? -1 : 1 + (int)(it - ds.begin()); + }; + if (auto pos = parentModel.find("-pro"); pos != std::string::npos) { + std::string flash = parentModel; + flash.replace(pos, 4, "-flash"); + if (int i = indexOf(flash); i > 0) return i; + if (int i = indexOf("deepseek-flash"); i > 0) return i; + } + if (int i = indexOf(parentModel); i > 0) return i; + return parentIdx; +} + +// Added to a subagent's system prompt. +constexpr const char* kSubagentNote = + "\n\nYou are a subagent: another agent delegated this self-contained task " + "to you. Work on it with your tools, then finish with a concise report of " + "what you found or did; that report is all the other agent gets back. You " + "are read-only: do not create, edit or delete files, and use bash only to " + "inspect (grep, git log, running tests). Nobody can answer questions, so " + "state your assumptions in the report."; + +// Model that writes a project brief: one of the provider the user picked, +// so the folder's README and file names go to no one else. DeepSeek: its +// Flash model (cheap and fast); Claude: Sonnet, through the CLI; the free +// model: itself. +int BriefModelIndex(int idx, const std::vector& remoteModels) { + const ModelRoute route = RouteForIndex(idx, remoteModels); + if (!route.claudeCli) return SubagentModelIndex(route.model, idx, remoteModels); + for (int i = 0; i < kClaudeModelCount; ++i) + if (std::string_view(kClaudeModels[i].id).find("sonnet") != std::string_view::npos) + return kClaudeIndexBase + i; + return idx; +} + // String field of a history/event object, or empty when missing or not a // string. Imported sessions and CLI events are untrusted shapes; value() // would throw on a type mismatch. @@ -497,6 +541,55 @@ Block NoticeBlock(const wxString& msg) { return b; } +// A collapsed tool call block: "name(args)" with the result inside. +Block ToolBlock(const std::string& name, const std::string& argsJson, const std::string& result) { + // Compact the args JSON for display: parse + dump to drop whitespace. + std::string displayArgs = argsJson; + try { + if (!argsJson.empty()) { + displayArgs = nlohmann::json::parse(argsJson).dump( + -1, ' ', false, nlohmann::json::error_handler_t::replace); + } + } catch (...) { /* show raw */ } + + std::string preview = result; + constexpr size_t kPreviewCap = 4000; + if (preview.size() > kPreviewCap) { + preview.resize(kPreviewCap); + preview += "\n... [preview truncated; full output sent to model]"; + } + + Block b; + b.type = BlockType::ToolCall; + b.toolName = wxString::FromUTF8(name); + b.toolArgs = wxString::FromUTF8(displayArgs); + b.toolResult = wxString::FromUTF8(preview); + b.toolExpanded = false; + // visibleText layout: + // [0, headerLen) "toolName(args)" + // [headerLen, headerLen+2) "\n\n" separator + // [headerLen+2, end) toolResult + // Header chars are selectable in the rendered header strip; body chars + // are selectable only when expanded. Selecting across a collapsed block + // (drag from another block, through this one) still grabs the full + // visibleText, so Ctrl+C captures both the header and the hidden body. + b.visibleText = b.toolName + "(" + b.toolArgs + ")\n\n" + b.toolResult; + return b; +} + +// Notice ending in a link that opens thread `id` (see OnCanvasLink). +Block ThreadLinkBlock(const wxString& msg, const std::string& id, const wxString& label) { + Block b = NoticeBlock(msg); + InlineRun link; + link.text = label; + link.bold = true; + link.link = wxString::FromUTF8("gritcode://thread/" + id); + b.runs.push_back(link); + b.rawText += label; + b.visibleText += label; + return b; +} + std::string Truncated(const std::string& s, size_t max) { if (s.size() <= max) return s; return s.substr(0, max) + "\n[truncated]"; @@ -624,18 +717,6 @@ std::string ClaudeToolResultText(const nlohmann::json& content) { return out; } -// chdir() into the session's directory so tool subprocesses (bash, -// list_directory with relative paths, etc.) operate against it. If the -// directory no longer exists or isn't accessible, fall back to $HOME so -// we don't strand subprocesses in some inherited cwd. -void ChdirToCwd(const std::string& cwd) { - if (cwd.empty()) return; - if (chdir(cwd.c_str()) == 0) return; - if (const char* home = std::getenv("HOME")) { - chdir(home); - } -} - // Resolve $HOME as the default cwd when the index has no last-active entry. std::string DefaultCwd() { if (const char* home = std::getenv("HOME")) { @@ -674,6 +755,40 @@ std::string FindGitRoot(const std::string& cwd) { return std::string(); } +// For a top-level thread in a git repo. Threads share the checkout with no +// lock, so the agent mustn't move its branch under the others, and takes +// branch work, or bigger work while another thread is busy here, into a +// worktree of its own. Small edits stay in place, so most work makes none. +std::string SharedCheckoutNote(const std::string& repo, const std::string& worktreesDir) { + const std::string dir = + worktreesDir + "/" + std::filesystem::path(repo).filename().string() + "/"; + return "Other conversation threads may work in " + repo + " at the same time: " + "they share its files and its checked-out branch. So:\n" + "- Don't change this checkout's branch or index (switching branches, " + "stash, reset, rebase, pull): it would change under the other threads. " + "If the user asks for that, say so and offer a worktree.\n" + "- By default, work here directly, and read a file again right before " + "editing it.\n" + "- Use a git worktree only when the user wants the work on its own branch, " + "or when another thread is working here right now (a note on the user's " + "message says so; it is for you, so don't bring it up unless it matters) " + "and your change is more than a small edit: `git worktree " + "add -b " + dir + " HEAD` with a short branch name (or reuse one " + "from `git worktree list`), then work only there, with absolute paths and " + "`cd` in bash. Tell the user its path and branch, and that uncommitted " + "changes here aren't in it.\n" + "- When the work in a worktree is done, commit it there and tell the user " + "how to bring it in (`git merge ` here). Leave the worktree unless " + "the user asks to remove it."; +} + +// The repo `cwd` is in, else `cwd`: threads in folders of one repo share its +// checkout. +std::string CheckoutOf(const std::string& cwd) { + std::string repo = FindGitRoot(cwd); + return repo.empty() ? cwd : repo; +} + bool ReadTextFile(const std::string& path, std::string& out) { std::ifstream in(path, std::ios::binary); if (!in) return false; @@ -719,9 +834,8 @@ std::string LoadAgentsInstructions(const std::string& cwd) { } // The static base of the system prompt (platform info + tool guidance). -// AGENTS.md instructions are appended to this by both SeedSystemPrompt and -// RefreshSystemPromptAgents so the same base is used for fresh and restored -// sessions alike. +// SystemPrompt appends AGENTS.md and the folder context, so fresh and +// restored threads share the same base. std::string BaseSystemPrompt() { std::string platformInfo; #ifdef _WIN32 @@ -759,6 +873,17 @@ std::string BaseSystemPrompt() { "what you have rather than firing speculative variants."; } +// When a thread was last used, short: "14:32" today, "Oct 12" earlier this +// year, else the date. +wxString LastUsedLabel(const std::string& iso) { + wxDateTime when; + if (!when.ParseISOCombined(wxString::FromUTF8(iso))) return wxString(); + const wxDateTime now = wxDateTime::Now(); + if (when.IsSameDate(now)) return when.Format("%H:%M"); + if (when.GetYear() == now.GetYear()) return when.Format("%b %d"); + return when.FormatISODate(); +} + // Format an absolute path for display in the dropdown. Replaces $HOME with // "~" and drops trailing slashes — matches the typical shell/IDE convention. wxString DisplayPath(const std::string& cwd) { @@ -772,6 +897,17 @@ wxString DisplayPath(const std::string& cwd) { return wxString::FromUTF8(cwd); } +// Default thread title: the first line of the first user message, shortened +// to fit the dropdown. +std::string DefaultTitle(const std::string& text) { + constexpr size_t kMaxChars = 50; + wxString title = wxString::FromUTF8(text.substr(0, text.find('\n'))); + title.Trim().Trim(false); + if (title.length() > kMaxChars) + title = title.Left(kMaxChars - 1) + wxString::FromUTF8("\xE2\x80\xA6"); + return title.utf8_string(); +} + // Resolve the assets directory using wxStandardPaths — the standard // wxWidgets way to find installed data files cross-platform. // Dev builds fall back to the source tree path baked in at compile time. @@ -1059,7 +1195,7 @@ ChatFrame::ChatFrame() outer->Add(canvas_, 1, wxEXPAND); // Toolbar row below the input: - // Session: [▾] Model: [▾] [⚙] + // Thread: [▾] Model: [▾] [⚙] const wxSize kIconSize(20, 20); const wxSize kBtnSize(FromDIP(32), -1); wxColour accent = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT); @@ -1072,7 +1208,7 @@ ChatFrame::ChatFrame() hamburgerBtn_ = new wxBitmapButton(panel, ID_HAMBURGER, bbHamburger, wxDefaultPosition, kBtnSize, wxBORDER_NONE); - hamburgerBtn_->SetToolTip(wxString::FromUTF8("Toggle session reference")); + hamburgerBtn_->SetToolTip(wxString::FromUTF8("Toggle side pane (session reference, threads)")); sessionLabel_ = new wxStaticText(panel, wxID_ANY, "Session:"); sessionChoice_ = new wxChoice(panel, ID_SESSION); @@ -1244,11 +1380,13 @@ ChatFrame::ChatFrame() editorPanel_->Hide(); // Import viewer — left pane of splitter, hidden until hamburger toggle. + // A Session | Threads switch on top picks what it shows. importPanel_ = new wxPanel(splitter_); + loadView_ = new wxPanel(importPanel_); auto* importSizer = new wxBoxSizer(wxVERTICAL); // Empty state: centered "Load Session…" button. - importEmptyView_ = new wxPanel(importPanel_); + importEmptyView_ = new wxPanel(loadView_); auto* emptySizer = new wxBoxSizer(wxVERTICAL); emptySizer->AddStretchSpacer(1); auto* loadBtn = new wxButton(importEmptyView_, wxID_ANY, @@ -1261,19 +1399,19 @@ ChatFrame::ChatFrame() OnImport(dummy); }); - importCanvas_ = new ChatCanvas(importPanel_); + importCanvas_ = new ChatCanvas(loadView_); importCanvas_->SetBgColour( wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); importCanvas_->Hide(); - refLabel_ = new wxStaticText(importPanel_, wxID_ANY, + refLabel_ = new wxStaticText(loadView_, wxID_ANY, wxString::FromUTF8("Referenced Session: None")); auto refFont = refLabel_->GetFont(); refFont.SetPointSize(refFont.GetPointSize() - 1); refLabel_->SetFont(refFont); refLabel_->SetForegroundColour(wxColour(140, 140, 140)); - auto* instructionLabel = new wxStaticText(importPanel_, wxID_ANY, + auto* instructionLabel = new wxStaticText(loadView_, wxID_ANY, wxString::FromUTF8("Click a prompt to copy it to the current session.")); auto instrFont = instructionLabel->GetFont(); instrFont.SetPointSize(instrFont.GetPointSize() - 1); @@ -1285,7 +1423,7 @@ ChatFrame::ChatFrame() auto* refRow = new wxBoxSizer(wxHORIZONTAL); refRow->Add(refLabel_, 0, wxALIGN_CENTER_VERTICAL); - auto* changeBtn = new wxButton(importPanel_, wxID_ANY, + auto* changeBtn = new wxButton(loadView_, wxID_ANY, wxString::FromUTF8("load another\xE2\x80\xA6"), wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); changeBtn_ = changeBtn; @@ -1309,9 +1447,53 @@ ChatFrame::ChatFrame() importSizer->Add(importEmptyView_, 1, wxEXPAND); importSizer->Add(importCanvas_, 1, wxEXPAND); importSizer->Add(bottomBox, 0, wxEXPAND); + loadView_->SetSizer(importSizer); + + threadsView_ = new wxPanel(importPanel_); + { + threadList_ = new ThreadList(threadsView_); + // Switch after the click is handled: the switch replaces the rows. + threadList_->Bind(wxEVT_LISTBOX, [this](wxCommandEvent& e) { + const std::string id = threadList_->Rows()[e.GetSelection()].id; + CallAfter([this, id] { + if (!SelectThread(id)) RefreshSessionChoice(); // snap back + }); + }); + threadList_->Bind(wxEVT_CONTEXT_MENU, &ChatFrame::OnThreadListContextMenu, this); + auto* sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(threadList_, 1, wxEXPAND); + threadsView_->SetSizer(sizer); + } + + // Bottom toolbar, like the main pane's: the view icons, and "New thread" + // on the right in the Threads view. Bitmaps come from PaintLeftTabs. + sessionTab_ = new wxBitmapButton(importPanel_, wxID_ANY, wxBitmapBundle(), + wxDefaultPosition, kBtnSize, wxBORDER_NONE); + sessionTab_->SetToolTip("Session reference: a saved session to copy prompts from"); + threadsTab_ = new wxBitmapButton(importPanel_, wxID_ANY, wxBitmapBundle(), + wxDefaultPosition, kBtnSize, wxBORDER_NONE); + threadsTab_->SetToolTip("This folder's threads"); + newThreadBtn_ = new wxBitmapButton(importPanel_, wxID_ANY, wxBitmapBundle(), + wxDefaultPosition, kBtnSize, wxBORDER_NONE); + newThreadBtn_->SetToolTip("New thread"); + sessionTab_->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { ShowLeftView(false); }); + threadsTab_->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { ShowLeftView(true); }); + newThreadBtn_->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { NewThread(activeCwd_); }); + auto* leftToolbar = new wxBoxSizer(wxHORIZONTAL); + leftToolbar->Add(sessionTab_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); + leftToolbar->Add(threadsTab_, 0, wxALIGN_CENTER_VERTICAL); + leftToolbar->AddStretchSpacer(); + leftToolbar->Add(newThreadBtn_, 0, wxALIGN_CENTER_VERTICAL); + // As tall as the main toolbar, whose dropdowns set its height, so the + // two line up. + leftToolbar->SetMinSize(wxSize(-1, sessionChoice_->GetBestSize().y)); + auto* importRoot = new wxBoxSizer(wxVERTICAL); - importRoot->Add(importSizer, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(kEdgePad)); + importRoot->Add(loadView_, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(kEdgePad)); + importRoot->Add(threadsView_, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(kEdgePad)); + importRoot->Add(leftToolbar, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(kEdgePad)); importPanel_->SetSizer(importRoot); + ShowLeftView(Preferences::GetLeftPaneThreads()); importPanel_->Hide(); // hidden until split // Click on import canvas copies prompt to main input. @@ -1328,6 +1510,10 @@ ChatFrame::ChatFrame() e.Skip(); }); + threadPaneTimer_.SetOwner(this); + Bind(wxEVT_TIMER, [this](wxTimerEvent&) { UpdateThreadPane(FolderRows()); }, + threadPaneTimer_.GetId()); + splitter_->Initialize(innerSplitter_); // Right pane only at start innerSplitter_->Initialize(mainPanel_); // Editor hidden at start @@ -1340,41 +1526,26 @@ ChatFrame::ChatFrame() frameSizer->Add(splitter_, 1, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(kEdgePad)); SetSizer(frameSizer); - // Open the most recent session if one exists; otherwise seed one for the - // default cwd so the dropdown always has at least one entry. { PERF_SCOPE("SessionStore::Init"); store_.Init(); } // Open the FTS5 memory index. Failure (e.g. read-only home) is silent; // grit_history_search returns "Memory index unavailable" in that case. { PERF_SCOPE("MemoryDB::Open"); memory_.Open(MemoryDB::DefaultPath()); } - bool restored = false; { PERF_SCOPE("RestoreLastSession"); - if (auto last = store_.LastActiveCwd()) { - std::vector hist; - if (store_.Load(*last, hist)) { - history_ = std::move(hist); - activeCwd_ = *last; - restored = true; - } - } - if (!restored && !store_.List().empty()) { - const auto& e = store_.List().front(); // most recent - std::vector hist; - if (store_.Load(e.cwd, hist)) { - history_ = std::move(hist); - activeCwd_ = e.cwd; - restored = true; - } - } - if (!restored) { - activeCwd_ = DefaultCwd(); - SeedSystemPrompt(); - { PERF_SCOPE("PersistActive:Save"); store_.Save(activeCwd_, history_); } - store_.SetLastActiveCwd(activeCwd_); - } else { - RefreshSystemPromptAgents(); - } + // Open the last active thread. An index written by 0.5.x only names the + // folder, whose first thread is used; with no index at all, the most + // recent thread, or else a fresh thread for the default cwd. Nothing is + // written here: the thread is saved once it changes. + std::string cwd = store_.LastActiveCwd().value_or(std::string()); + std::string id = store_.LastActiveThread().value_or(std::string()); + if (cwd.empty() && !store_.List().empty()) { + cwd = store_.List().front().cwd; + id = store_.List().front().id; + } + if (cwd.empty()) cwd = DefaultCwd(); + if (id.empty()) id = SessionStore::IdForCwd(cwd); + OpenThread(id, cwd); + activeCwd_ = Active().cwd; } // RestoreLastSession - ChdirToCwd(activeCwd_); RefreshSessionChoice(); PopulateEditorTree(); // Some watcher backends need a running event loop before they initialise, @@ -1390,7 +1561,7 @@ ChatFrame::ChatFrame() // callback from its first OnPaint). Small sessions are restored // synchronously, as before. canvas_->SetLoadingPaintCallback([this] { RestoreSession(); }); - if (HistoryIsLarge()) { + if (HistoryIsLarge(Active())) { canvas_->SetLoading(true); } else { RestoreCanvasFromHistory(); @@ -1425,7 +1596,6 @@ ChatFrame::ChatFrame() Bind(wxEVT_MENU, &ChatFrame::OnEditorCloseFile, this, ID_EDITOR_CLOSE); Bind(wxEVT_MENU, &ChatFrame::OnEditorShowInFiles, this, ID_EDITOR_SHOW_IN_FILES); Bind(wxEVT_BUTTON, &ChatFrame::OnPlay, this, ID_PLAY); - Bind(wxEVT_TOOL_BATCH_DONE, &ChatFrame::OnToolBatchDone, this); sessionChoice_->Bind(wxEVT_CHOICE, &ChatFrame::OnSessionChoice, this); modelChoice_->Bind(wxEVT_CHOICE, &ChatFrame::OnModelChoice, this); modelChoice_->Bind(wxEVT_CONTEXT_MENU, &ChatFrame::OnModelContextMenu, this); @@ -1461,14 +1631,17 @@ ChatFrame::ChatFrame() MCPCallbacks cb; cb.getStatus = [this, guiSync]() { return guiSync([this]() -> nlohmann::json { + const Thread& t = Active(); nlohmann::json queue = nlohmann::json::array(); - for (const auto& q : pendingQueue_) queue.push_back(q.text); + for (const auto& q : t.pendingQueue) queue.push_back(q.text); return { - {"streaming", streaming_}, - {"toolIter", toolIter_}, - {"historyLen", (int)history_.size()}, + {"threadId", t.id}, + {"streaming", t.streaming}, + {"toolIter", t.toolIter}, + {"historyLen", (int)t.history.size()}, {"pendingQueue", std::move(queue)}, {"continueQueueVisible", continueQueueBtn_->IsShown()}, + {"input", input_->GetValue().utf8_string()}, }; }); }; @@ -1477,7 +1650,8 @@ ChatFrame::ChatFrame() }; cb.getLastAssistant = [this, guiSync]() { return guiSync([this]() -> nlohmann::json { - for (auto it = history_.rbegin(); it != history_.rend(); ++it) { + const Thread& t = Active(); + for (auto it = t.history.rbegin(); it != t.history.rend(); ++it) { if (it->value("role", std::string{}) != "assistant") continue; if (!it->contains("content") || !(*it)["content"].is_string()) continue; return {{"text", (*it)["content"].get()}}; @@ -1491,13 +1665,15 @@ ChatFrame::ChatFrame() // GUI thread under guiSync so the caller learns immediately whether // the message was accepted (or queued, or rejected as full). return guiSync([this, text]() -> nlohmann::json { - if (streaming_) { - if (pendingQueue_.size() >= kMaxQueue_) { + Thread& t = Active(); + if (!t.parent.empty()) return {{"sent", false}, {"reason", "subagent thread"}}; + if (t.streaming) { + if (t.pendingQueue.size() >= kMaxQueue_) { return {{"sent", false}, {"reason", "queue_full"}}; } - EnqueueMessage(text); + EnqueueMessage(t, text); return {{"sent", true}, {"queued", true}, - {"queueLen", (int)pendingQueue_.size()}}; + {"queueLen", (int)t.pendingQueue.size()}}; } input_->SetValue(text); CallAfter([this]() { @@ -1508,10 +1684,7 @@ ChatFrame::ChatFrame() }); }; cb.cancelRequest = [this]() { - CallAfter([this]() { - request_.Cancel(); - claudeProc_.Cancel(); - }); + CallAfter([this]() { RequestCancel(Active()); }); }; cb.getBlocks = [this, guiSync]() { return guiSync([this]() { return BuildBlocksSnapshot(); }); @@ -1534,10 +1707,43 @@ ChatFrame::ChatFrame() }; cb.switchSession = [this, guiSync](const std::string& cwd) -> nlohmann::json { return guiSync([this, cwd]() -> nlohmann::json { - if (streaming_) return {{"ok", false}, {"reason", "streaming"}}; if (cwd == activeCwd_) return {{"ok", true}}; if (!SwitchToCwd(cwd)) return {{"ok", false}, {"reason", "cancelled"}}; + return {{"ok", true}, {"threadId", Active().id}}; + }); + }; + cb.listThreads = [this, guiSync]() { + return guiSync([this]() -> nlohmann::json { + nlohmann::json arr = nlohmann::json::array(); + auto add = [&](const std::string& id, const std::string& cwd, + const std::string& title, const std::string& parent, + const std::string& lastUsed, const Thread* t) { + arr.push_back({ + {"id", id}, {"cwd", cwd}, {"title", title}, {"parent", parent}, + {"lastUsed", lastUsed}, {"loaded", t != nullptr}, + {"running", t && t->streaming}, + {"failed", t && t->failureNotice.has_value()}, + {"unseen", t && t->unseen}, + }); + }; + for (const Thread& t : threads_) + add(t.id, t.cwd, t.title, t.parent, t.lastUsed, &t); + for (const auto& e : store_.List()) + if (!Find(e.id)) add(e.id, e.cwd, e.title, e.parent, e.lastUsed, nullptr); + return {{"threads", std::move(arr)}, {"activeId", Active().id}}; + }); + }; + cb.newThread = [this, guiSync](const std::string& cwd) -> nlohmann::json { + return guiSync([this, cwd]() -> nlohmann::json { + if (!NewThread(cwd.empty() ? activeCwd_ : cwd)) + return {{"ok", false}, {"reason", "cancelled"}}; + return {{"ok", true}, {"threadId", Active().id}}; + }); + }; + cb.selectThread = [this, guiSync](const std::string& id) -> nlohmann::json { + return guiSync([this, id]() -> nlohmann::json { + if (!SelectThread(id)) return {{"ok", false}, {"reason", "not found"}}; return {{"ok", true}}; }); }; @@ -1552,10 +1758,15 @@ ChatFrame::ChatFrame() return {{"ok", true}, {"modelIndex", currentModelIndex_}}; }); }; - cb.getPreferences = [guiSync]() -> nlohmann::json { - return guiSync([]() -> nlohmann::json { + cb.getPreferences = [this, guiSync]() -> nlohmann::json { + return guiSync([this]() -> nlohmann::json { + // The model dropdown's rows, as setModel takes them. + nlohmann::json models = nlohmann::json::array(); + for (unsigned i = 0; i < modelChoice_->GetCount(); ++i) + models.push_back(modelChoice_->GetString(i).utf8_string()); return { {"modelIndex", Preferences::GetLastModelIndex()}, + {"models", std::move(models)}, {"hasDeepseekKey", Preferences::HasApiKey(Preferences::Provider::DeepSeek)}, {"enableGritHistory", Preferences::GetEnableGritHistory()}, @@ -1647,44 +1858,27 @@ ChatFrame::ChatFrame() }; cb.newSession = [this, guiSync]() -> nlohmann::json { return guiSync([this]() -> nlohmann::json { - if (streaming_) return {{"ok", false}, {"reason", "streaming"}}; - // MCP-driven new session uses the index's default cwd as a fresh - // sentinel rather than popping a directory dialog (the dialog - // would block the GUI thread and is awkward to drive from tests). - // The interactive flow goes through OnSessionChoice → directory - // picker. Tests can use switchSession with an arbitrary cwd to - // create entries on demand. - std::string newCwd = DefaultCwd(); - PersistActive(); - std::vector hist; - if (store_.Load(newCwd, hist)) { - activeCwd_ = newCwd; - history_ = std::move(hist); - RefreshSystemPromptAgents(); - } else { - activeCwd_ = newCwd; - history_.clear(); - SeedSystemPrompt(); - store_.Save(activeCwd_, history_); - } - store_.SetLastActiveCwd(activeCwd_); - ChdirToCwd(activeCwd_); - canvas_->Clear(); - RestoreCanvasFromHistory(); - RefreshSessionChoice(); + // MCP-driven new session opens the default cwd rather than popping + // a directory dialog (the dialog would block the GUI thread and is + // awkward to drive from tests). The interactive flow goes through + // OnSessionChoice → directory picker. Tests can use switchSession + // with an arbitrary cwd to create entries on demand. + if (!SwitchToCwd(DefaultCwd())) + return {{"ok", false}, {"reason", "cancelled"}}; return {{"ok", true}, {"cwd", activeCwd_}}; }); }; cb.exportSession = [this, guiSync](const std::string& path) -> nlohmann::json { return guiSync([this, path]() -> nlohmann::json { + const Thread& t = Active(); nlohmann::json j; - j["version"] = HistoryHasImages(history_) ? 2 : 1; - j["messages"] = history_; + j["version"] = HistoryHasImages(t.history) ? 2 : 1; + j["messages"] = t.history; std::string err; bool ok = false; - if (HistoryHasImages(history_)) { - ok = ExportToZip(path, j, CollectImageRefs(history_), err); + if (HistoryHasImages(t.history)) { + ok = ExportToZip(path, j, CollectImageRefs(t.history), err); } else { std::ofstream f(path, std::ios::binary | std::ios::trunc); ok = (bool)f; @@ -1693,7 +1887,7 @@ ChatFrame::ChatFrame() else err = "cannot write file"; } if (!ok) return {{"ok", false}, {"error", err}}; - return {{"ok", true}, {"messageCount", (int)history_.size()}}; + return {{"ok", true}, {"messageCount", (int)t.history.size()}}; }); }; cb.importSession = [this, guiSync](const std::string& path) @@ -1726,9 +1920,15 @@ ChatFrame::ChatFrame() return {{"ok", true}, {"promptCount", promptCount}}; }); }; + cb.setInput = [this, guiSync](const std::string& text) -> nlohmann::json { + return guiSync([this, text]() -> nlohmann::json { + input_->SetValue(wxString::FromUTF8(text)); + return {{"ok", true}}; + }); + }; cb.play = [this, guiSync]() -> nlohmann::json { return guiSync([this]() -> nlohmann::json { - if (streaming_) return {{"started", false}, {"reason", "streaming"}}; + if (Active().streaming) return {{"started", false}, {"reason", "streaming"}}; const bool configured = RunConfigStore::Get(activeCwd_).has_value(); wxCommandEvent ev(wxEVT_BUTTON, ID_PLAY); OnPlay(ev); @@ -1736,7 +1936,18 @@ ChatFrame::ChatFrame() }); }; auto panes = [this]() -> nlohmann::json { + nlohmann::json rows = nlohmann::json::array(); + for (const ThreadRow& r : threadList_->Rows()) + rows.push_back({{"id", r.id}, {"title", r.title.utf8_string()}, + {"detail", r.detail.utf8_string()}, {"child", r.child}, + {"state", std::array{"done", "running", "unseen", "failed"}[(int)r.state]}}); + nlohmann::json sessions = nlohmann::json::array(); + for (unsigned i = 0; i < sessionChoice_->GetCount(); ++i) + sessions.push_back(sessionChoice_->GetString(i).utf8_string()); return {{"window", GetClientSize().x}, + {"sessions", std::move(sessions)}, + {"threads", ThreadsShown() ? importPanel_->GetSize().x : 0}, + {"threadRows", std::move(rows)}, {"import", splitter_->IsSplit() ? importPanel_->GetSize().x : 0}, {"chat", mainPanel_->GetSize().x}, {"editor", innerSplitter_->IsSplit() ? editorPanel_->GetSize().x : 0}}; @@ -1744,8 +1955,19 @@ ChatFrame::ChatFrame() cb.togglePanel = [this, guiSync, panes](const std::string& panel) -> nlohmann::json { return guiSync([this, panel, panes]() -> nlohmann::json { wxCommandEvent ev(wxEVT_BUTTON); - if (panel == "editor") OnEditorToggle(ev); - else OnHamburger(ev); + if (panel == "editor") { + OnEditorToggle(ev); + } else { + // "import" and "threads" are the left pane's two views: close + // it if it shows that view, else open it on that view. + const bool threads = panel == "threads"; + if (splitter_->IsSplit() && threadsView_->IsShown() == threads) { + OnHamburger(ev); + } else { + ShowLeftView(threads); + if (!splitter_->IsSplit()) OnHamburger(ev); + } + } return panes(); }); }; @@ -1886,10 +2108,10 @@ bool ChatFrame::TryPasteImage() { return true; } -bool ChatFrame::HistoryIsLarge() const { - if (history_.size() > 200) return true; +bool ChatFrame::HistoryIsLarge(const Thread& t) { + if (t.history.size() > 200) return true; size_t chars = 0; - for (const auto& m : history_) { + for (const auto& m : t.history) { if (!m.is_object()) continue; if (m.contains("content") && m["content"].is_string()) chars += m["content"].get().size(); @@ -1908,7 +2130,7 @@ bool ChatFrame::HistoryIsLarge() const { void ChatFrame::RestoreCanvasMaybeDeferred() { canvas_->Clear(); - if (HistoryIsLarge()) { + if (HistoryIsLarge(Active())) { // The canvas fires onLoadingPainted_ (set once in the constructor) // from its first paint of the placeholder, which runs RestoreSession. // No direct CallAfter here — that would run RestoreSession twice. @@ -1924,40 +2146,31 @@ ChatFrame::~ChatFrame() { // on a thread waiting for a CallAfter that can no longer fire. destroying_.store(true); mcp_.Stop(); - request_.Cancel(); - // Kills a running Claude Code turn and joins its worker, so no callback - // can land on a half-destroyed frame. - claudeProc_ = ClaudeAgentProcess(); + for (Thread& t : threads_) { + // Joins the request's worker, so no more callbacks can be posted. + t.request = StreamingWebRequest(); + // Kills a running Claude Code turn and joins its worker, so no + // callback can land on a half-destroyed frame. + t.claudeProc = ClaudeAgentProcess(); + // Tool worker isn't detached — cancel any in-flight bash subtree and + // join so the worker can't post to a half-destroyed frame. + if (t.toolWorker.joinable()) { + if (auto tok = t.currentToolToken) { + tok->cancelled.store(true); +#ifndef _WIN32 + int pgid = tok->activePgid.load(); + if (pgid > 0) ::kill(-pgid, SIGTERM); +#endif + } + t.toolWorker.join(); + } + } delete fileWatcher_; // stop filesystem watches (own their thread) fileWatcher_ = nullptr; delete themeWatcher_; themeWatcher_ = nullptr; themeTimer_.Stop(); - // ~StreamingWebRequest joins the worker thread, so by the time we return - // no more callbacks can be posted. - - // Tool worker isn't detached — cancel any in-flight bash subtree and join - // so the worker can't fire wxQueueEvent on a half-destroyed frame. - if (toolWorker_.joinable()) { - if (auto tok = currentToolToken_) { - tok->cancelled.store(true); -#ifndef _WIN32 - int pgid = tok->activePgid.load(); - if (pgid > 0) ::kill(-pgid, SIGTERM); -#endif - } - toolWorker_.join(); - } - if (playWorker_.joinable()) { - if (auto tok = currentPlayToken_) { - tok->cancelled.store(true); -#ifndef _WIN32 - int pgid = tok->activePgid.load(); - if (pgid > 0) ::kill(-pgid, SIGTERM); -#endif - } - playWorker_.join(); - } + for (PlayRun& run : plays_) StopPlay(run); if (persistWorker_.joinable()) persistWorker_.join(); if (remoteModelsWorker_.joinable()) remoteModelsWorker_.join(); } @@ -1995,7 +2208,7 @@ nlohmann::json ChatFrame::BuildBlocksSnapshot() const { wxString preview = b.visibleText.Length() > kPreview ? b.visibleText.Left(kPreview) + "..." : b.visibleText; - e["preview"] = preview.ToStdString(); + e["preview"] = preview.utf8_string(); // UTF-8 whatever the locale if (b.type == BlockType::Heading) { e["level"] = b.headingLevel; } else if (b.type == BlockType::CodeBlock) { @@ -2028,7 +2241,7 @@ nlohmann::json ChatFrame::BuildBlocksSnapshot() const { nlohmann::json ChatFrame::BuildConversationSnapshot() const { nlohmann::json out = nlohmann::json::array(); - for (const auto& m : history_) { + for (const auto& m : Active().history) { nlohmann::json e; e["role"] = m.value("role", std::string{}); if (m.contains("content") && m["content"].is_string()) { @@ -2048,14 +2261,16 @@ nlohmann::json ChatFrame::BuildConversationSnapshot() const { } void ChatFrame::OnClose(wxCloseEvent& evt) { - // currentToolToken_ is non-null exactly while the tool-dispatch worker is - // running. Closing during a tool batch must veto + cancel just like + // Closing while a tool-dispatch worker runs must veto + cancel just like // closing during an in-flight HTTP request — otherwise the worker would - // outlive the frame and post wxQueueEvent to a dangling `this`. - if (request_.IsActive() || currentToolToken_ || claudeProc_.IsActive()) { + // outlive the frame and post to a dangling `this`. + auto busy = [](const Thread& t) { + return t.request.IsActive() || t.toolWorkerRunning || t.claudeProc.IsActive(); + }; + if (std::ranges::any_of(threads_, busy)) { quitRequested_ = true; Hide(); - RequestCancel(); + for (Thread& t : threads_) RequestCancel(t); // OnStreamDone / OnToolBatchDone / OnClaudeDone check quitRequested_ // and re-fire Close() once their phase finishes. evt.Veto(); @@ -2064,7 +2279,7 @@ void ChatFrame::OnClose(wxCloseEvent& evt) { evt.Veto(); return; } - PersistActive(); + Persist(Active()); evt.Skip(); } } @@ -2086,8 +2301,8 @@ void ChatFrame::OnInputKey(wxKeyEvent& e) { } void ChatFrame::OnCharHook(wxKeyEvent& e) { - if (e.GetKeyCode() == WXK_ESCAPE && streaming_) { - RequestCancel(); + if (e.GetKeyCode() == WXK_ESCAPE && Active().streaming) { + RequestCancel(Active()); return; } int key = e.GetKeyCode(); @@ -2098,23 +2313,34 @@ void ChatFrame::OnCharHook(wxKeyEvent& e) { e.Skip(); } -void ChatFrame::RequestCancel() { +void ChatFrame::RequestCancel(Thread& t) { + // A Claude Code subagent runs inside its parent's Claude Code process: + // cancelling it cancels that run, which ends the subagent too. + if (Thread* p = t.parent.empty() ? nullptr : Find(t.parent); + p && p->claudeProc.IsActive()) { + RequestCancel(*p); + return; + } + if (t.streaming) t.stopRequested = true; // Idempotent on a finished request — Cancel just sets an atomic. - request_.Cancel(); + t.request.Cancel(); // A Claude turn gets SIGINT, so Claude Code ends it cleanly. - claudeProc_.Cancel(); + t.claudeProc.Cancel(); // Signal the tool worker. Setting `cancelled` makes the worker bail out // between tools and the bash poll loop bail mid-tool. Sending SIGTERM to // the active pgid kills the bash subtree without waiting for the worker // to notice — important when bash is blocked on something slow. - if (auto tok = currentToolToken_) { + if (auto tok = t.currentToolToken) { tok->cancelled.store(true); #ifndef _WIN32 int pgid = tok->activePgid.load(); if (pgid > 0) ::kill(-pgid, SIGTERM); #endif + // The batch waits for its subagents: they are cancelled too. + for (Thread& c : threads_) + if (c.parent == t.id && c.streaming) RequestCancel(c); } - // Note: Play-button worker (currentPlayToken_) is intentionally NOT + // Note: Play-button runs (plays_) are intentionally NOT // cancelled here. Escape cancels AI-initiated tool calls, not user- // initiated Play runs. If the user started a dev server via Play, // they want it to keep running. @@ -2147,8 +2373,10 @@ void ChatFrame::ApplyTheme() { importCanvas_->SetBgColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); importCanvas_->ApplyTheme(); changeBtn_->SetForegroundColour(importCanvas_->GetPalette().toolAccent); + threadList_->Refresh(); ReloadToolbarIcons(); - if (!pendingQueue_.empty()) RebuildChips(); + if (!Active().pendingQueue.empty()) RebuildChips(); + // Re-colour the open file with the new syntax palette. if (codeEdit_ && !editorFilePath_.empty() && highlightTimer_) highlightTimer_->StartOnce(1); @@ -2162,196 +2390,491 @@ void ChatFrame::ReloadToolbarIcons() { playBtn_->SetBitmap(LoadThemedSvgIcon("play.svg", kIconSize, accent)); exportBtn_->SetBitmap(LoadThemedSvgIcon("export.svg", kIconSize, accent)); hamburgerBtn_->SetBitmap(LoadThemedSvgIcon("hamburger.svg", kIconSize, accent)); + PaintLeftTabs(); editorBtn_->SetBitmap(LoadThemedSvgIcon("editor.svg", kIconSize, accent)); } -void ChatFrame::RefreshSessionChoice() { - PERF_SCOPE("RefreshSessionChoice"); - sessionChoice_->Clear(); - sessionCwds_.clear(); - sessionChoice_->Append(wxString::FromUTF8("New Session\xE2\x80\xA6")); - int activeRow = -1; - for (const auto& e : store_.List()) { - sessionChoice_->Append(DisplayPath(e.cwd)); - sessionCwds_.push_back(e.cwd); - if (e.cwd == activeCwd_) activeRow = (int)sessionCwds_.size(); - } - if (activeRow < 1 && sessionCwds_.size() >= 1) activeRow = 1; - if (activeRow >= 1) sessionChoice_->SetSelection(activeRow); - if (activeRow >= 1) { - sessionChoice_->SetToolTip( - wxString::FromUTF8(sessionCwds_[activeRow - 1])); - } +ChatFrame::Thread* ChatFrame::Find(const std::string& id) { + auto it = std::ranges::find(threads_, id, &Thread::id); + return it != threads_.end() ? &*it : nullptr; } -void ChatFrame::OnSessionChoice(wxCommandEvent& evt) { - int sel = evt.GetSelection(); - if (sel == 0) { - // "New Session…" sentinel — snap back. - for (int i = 0; i < (int)sessionCwds_.size(); ++i) { - if (sessionCwds_[i] == activeCwd_) { - sessionChoice_->SetSelection(i + 1); - break; - } - } - if (streaming_) { - wxMessageBox("Cannot switch sessions while streaming.", - "gritcode", wxOK | wxICON_INFORMATION, this); - return; +ChatFrame::Thread* ChatFrame::Find(const std::string& id, uint32_t gen) { + Thread* t = Find(id); + return t && t->gen == gen ? t : nullptr; +} + +wxString ChatFrame::ThreadLabel(const std::string& id, const std::string& cwd, + const std::string& title) { + if (!title.empty()) return wxString::FromUTF8(title); + // A folder's first thread is its session from before threads existed, + // named by the folder as it always was. + if (id == SessionStore::IdForCwd(cwd)) return DisplayPath(cwd); + return "New thread"; +} + +wxString ChatFrame::ThreadLabelOf(const std::string& id) { + if (const Thread* t = Find(id)) return ThreadLabel(t->id, t->cwd, t->title); + const auto& entries = store_.List(); + auto e = std::ranges::find(entries, id, &SessionStore::Entry::id); + return e != entries.end() ? ThreadLabel(e->id, e->cwd, e->title) : wxString("thread"); +} + +std::vector ChatFrame::FolderRows() { + // Open threads carry the live state (running, title); the index adds the + // folder's threads that haven't been opened in this run. + struct Row { + ThreadRow row; + std::string parent; + std::string lastUsed; + }; + std::vector rows; + for (Thread& t : threads_) { + if (t.cwd != activeCwd_) continue; + ThreadRow r{.id = t.id, .title = ThreadLabel(t.id, t.cwd, t.title), + .detail = LastUsedLabel(t.lastUsed)}; + if (t.streaming) { + r.state = ThreadRow::State::Running; + r.detail = CurrentStep(t); + } else if (t.failureNotice && !Shown(t)) { // until viewed + r.state = ThreadRow::State::Failed; + r.detail = t.failureNotice->visibleText.BeforeFirst('\n'); + } else if (t.unseen) { + r.state = ThreadRow::State::Unseen; } - CreateNewSession(); - return; + rows.push_back({std::move(r), t.parent, t.lastUsed}); } - int idx = sel - 1; - if (idx < 0 || idx >= (int)sessionCwds_.size()) return; - const std::string& target = sessionCwds_[idx]; - if (target == activeCwd_) return; - if (streaming_) { - for (int i = 0; i < (int)sessionCwds_.size(); ++i) { - if (sessionCwds_[i] == activeCwd_) { - sessionChoice_->SetSelection(i + 1); - break; - } - } - wxMessageBox("Cannot switch sessions while streaming.", - "gritcode", wxOK | wxICON_INFORMATION, this); - return; + for (const auto& e : store_.List()) { + if (e.cwd != activeCwd_ || Find(e.id)) continue; + rows.push_back({{.id = e.id, .title = ThreadLabel(e.id, e.cwd, e.title), + .detail = LastUsedLabel(e.lastUsed)}, + e.parent, e.lastUsed}); } - if (!SwitchToCwd(target)) { - // Cancelled (unsaved editor changes): snap back to the active session. - for (int i = 0; i < (int)sessionCwds_.size(); ++i) { - if (sessionCwds_[i] == activeCwd_) { - sessionChoice_->SetSelection(i + 1); - break; - } + std::ranges::stable_sort(rows, std::greater{}, &Row::lastUsed); + + // Subagent threads go right under their parent. + auto listed = [&rows](const std::string& id) { + return std::ranges::find(rows, id, [](const Row& r) { return r.row.id; }) != rows.end(); + }; + std::vector ordered; + for (const Row& r : rows) { + if (!r.parent.empty() && listed(r.parent)) continue; + ordered.push_back(r.row); + for (const Row& c : rows) { + if (c.parent != r.row.id) continue; + ordered.push_back(c.row); + ordered.back().child = true; } } + return ordered; +} + +wxString ChatFrame::CurrentStep(Thread& t) { + if (t.compacting) return "compacting"; + // The tool calls without a result yet: a Claude message keeps its calls + // until their first result arrives, other turns record them in history + // before running them. + std::vector running; + auto unanswered = [&](const nlohmann::json& msg, const std::vector& answered) { + auto calls = msg.find("tool_calls"); + if (calls == msg.end() || !calls->is_array()) return; + for (const auto& c : *calls) + if (std::ranges::find(answered, StringField(c, "id")) == answered.end()) + running.push_back(StringField(c.value("function", nlohmann::json()), "name")); + }; + if (t.claudePendingMsg.is_object()) { + unanswered(t.claudePendingMsg, {}); + } else { + std::vector answered; + auto m = t.history.rbegin(); + for (; m != t.history.rend() && StringField(*m, "role") == "tool"; ++m) + answered.push_back(StringField(*m, "tool_call_id")); + if (m != t.history.rend() && StringField(*m, "role") == "assistant") + unanswered(*m, answered); + } + if (!running.empty()) { + wxString step = "running " + wxString::FromUTF8(running.front()); + if (running.size() > 1) step += FormatU8(" +{}", running.size() - 1); + return step; + } + return t.activeAssistantText.empty() ? "thinking" : "writing"; +} + +void ChatFrame::RefreshSessionChoice() { + PERF_SCOPE("RefreshSessionChoice"); + // One row per folder, most recent first. "● " marks a folder with a + // running thread; its threads are in the left pane. + // A folder whose threads aren't saved yet goes first. + sessionCwds_.clear(); + for (const auto& e : store_.List()) + if (std::ranges::find(sessionCwds_, e.cwd) == sessionCwds_.end()) + sessionCwds_.push_back(e.cwd); + if (!activeCwd_.empty() && std::ranges::find(sessionCwds_, activeCwd_) == sessionCwds_.end()) + sessionCwds_.insert(sessionCwds_.begin(), activeCwd_); + sessionChoice_->Clear(); + sessionChoice_->Append(wxString::FromUTF8("New Session\xE2\x80\xA6")); + // What the user hasn't seen in a folder: "⚠ " a thread failed, "● " one + // is working or finished since it was last viewed. The thread on screen + // doesn't count, and subagents report through their parent. + auto mark = [this](const std::string& cwd) { + bool busy = false; + for (const Thread& t : threads_) { + if (&t == &Active() || !t.parent.empty() || t.cwd != cwd) continue; + if (t.failureNotice) return wxString::FromUTF8("\xE2\x9A\xA0 "); + busy = busy || t.streaming || t.unseen; + } + return busy ? wxString::FromUTF8("\xE2\x97\x8F ") : wxString(); + }; + for (const std::string& cwd : sessionCwds_) + sessionChoice_->Append(mark(cwd) + DisplayPath(cwd)); + auto active = std::ranges::find(sessionCwds_, activeCwd_); + if (active != sessionCwds_.end()) + sessionChoice_->SetSelection(1 + (int)(active - sessionCwds_.begin())); + sessionChoice_->SetToolTip(wxString::FromUTF8(activeCwd_)); + if (ThreadsShown()) UpdateThreadPane(FolderRows()); +} + +void ChatFrame::UpdateThreadPane(std::vector rows) { + // While a thread runs its step changes without a refresh from the turn + // code, so poll at a low rate until none does. + const bool running = std::ranges::any_of(rows, [](const ThreadRow& r) { + return r.state == ThreadRow::State::Running; + }); + if (!running) threadPaneTimer_.Stop(); + else if (!threadPaneTimer_.IsRunning()) threadPaneTimer_.Start(1000); + threadList_->SetRows(std::move(rows), Active().id); +} + +void ChatFrame::OnSessionChoice(wxCommandEvent& evt) { + const int sel = evt.GetSelection(); + const std::string cwd = sel >= 1 && sel <= (int)sessionCwds_.size() + ? sessionCwds_[sel - 1] : std::string(); + // Snap back to the active folder; a successful switch reselects. + RefreshSessionChoice(); + if (sel == 0) CreateNewSession(); + else if (!cwd.empty() && cwd != activeCwd_) SwitchToCwd(cwd); } void ChatFrame::CreateNewSession() { - // Pop a directory picker so the user can name the session by folder. + // Pop a directory picker so the user can pick the folder to work in. // Default to $HOME so they start at a familiar root. wxString defaultDir = wxString::FromUTF8(DefaultCwd()); - wxDirDialog dlg(this, "Choose a folder for the new session", + wxDirDialog dlg(this, "Choose a folder", defaultDir, wxDD_DEFAULT_STYLE); if (dlg.ShowModal() != wxID_OK) return; std::string chosen = dlg.GetPath().ToStdString(wxConvUTF8); if (chosen.empty()) return; - if (chosen == activeCwd_) return; // already on this session - - if (!MaybeSaveEditor()) return; // cancelled: keep current session + editor - ClearEditorState(); - - PersistActive(); - activeCwd_ = chosen; - std::vector hist; - if (store_.Load(activeCwd_, hist)) { - // Existing session for this folder — restore it. - history_ = std::move(hist); - RefreshSystemPromptAgents(); - } else { - // Brand new folder: seed a fresh system prompt. - history_.clear(); - SeedSystemPrompt(); - store_.Save(activeCwd_, history_); - } - historyCompactBaseCount_ = 0; // fresh compaction gate for the new session - store_.SetLastActiveCwd(activeCwd_); - ChdirToCwd(activeCwd_); // keep the process cwd in sync with activeCwd_ - RestoreCanvasMaybeDeferred(); - RefreshSessionChoice(); - PopulateEditorTree(); - SetupFsWatcher(); + SwitchToCwd(chosen); } bool ChatFrame::SwitchToCwd(const std::string& cwd) { - PERF_SCOPE("SwitchToCwd"); - if (cwd == activeCwd_) return true; - if (!MaybeSaveEditor()) return false; // cancelled: keep current session - ClearEditorState(); + // A folder opens on its most recently used thread. One never used before + // starts on its first thread, whose id derives from the folder. + std::string id = SessionStore::IdForCwd(cwd); + std::string lastUsed; + for (const Thread& t : threads_) { + if (t.cwd == cwd && t.parent.empty() && t.lastUsed > lastUsed) { + id = t.id; + lastUsed = t.lastUsed; + } + } + for (const auto& e : store_.List()) { + if (e.cwd == cwd && e.parent.empty() && e.lastUsed > lastUsed) { + id = e.id; + lastUsed = e.lastUsed; + } + } + if (!Find(id)) OpenThread(id, cwd); + return ShowThread(id); +} - PersistActive(); - std::vector hist; - bool existed = store_.Load(cwd, hist); - activeCwd_ = cwd; - history_ = std::move(hist); - if (!existed) { - // Brand new folder: seed a fresh system prompt and persist so the - // session shows up in the index immediately. - history_.clear(); - SeedSystemPrompt(); - store_.Save(activeCwd_, history_); - } else { - RefreshSystemPromptAgents(); +bool ChatFrame::NewThread(const std::string& cwd) { + // Not saved until its first turn ends, so an unused one leaves nothing + // behind. + Thread& t = OpenThread(SessionStore::NewThreadId(), cwd); + if (ShowThread(t.id)) { + if (store_.LoadBrief(cwd).empty() && WriteProjectBrief(cwd)) AddBriefNotice(t); + return true; } - historyCompactBaseCount_ = 0; // fresh compaction gate for the new session - store_.SetLastActiveCwd(activeCwd_); - ChdirToCwd(activeCwd_); + threads_.pop_back(); // cancelled; nothing refers to it yet + return false; +} + +bool ChatFrame::SelectThread(const std::string& id) { + if (!Find(id)) { + const auto& entries = store_.List(); + auto e = std::ranges::find(entries, id, &SessionStore::Entry::id); + if (e == entries.end()) return false; + OpenThread(e->id, e->cwd); + } + return ShowThread(id); +} + +ChatFrame::Thread& ChatFrame::OpenThread(const std::string& id, + const std::string& cwd) { + Thread& t = threads_.emplace_back(); + t.id = id; + t.cwd = cwd; + t.lastUsed = SessionStore::NowIso(); + const auto& entries = store_.List(); + if (auto e = std::ranges::find(entries, id, &SessionStore::Entry::id); + e != entries.end()) { + t.title = e->title; + t.parent = e->parent; + t.lastUsed = e->lastUsed; + } + if (store_.Load(id, t.history)) { + RefreshSystemPrompt(t); + // Sessions from before threads have no title yet. + if (t.title.empty()) { + auto first = std::ranges::find_if(t.history, IsUserTurn); + if (first != t.history.end()) + t.title = DefaultTitle(StringField(*first, "content")); + } + } else { + SeedSystemPrompt(t); + } + return t; +} + +bool ChatFrame::ShowThread(const std::string& id) { + PERF_SCOPE("ShowThread"); + auto it = std::ranges::find(threads_, id, &Thread::id); + if (it == threads_.end()) return false; + const size_t idx = (size_t)(it - threads_.begin()); + if (idx == active_) return true; + const bool otherFolder = it->cwd != activeCwd_; + if (otherFolder) { + if (!MaybeSaveEditor()) return false; // cancelled: keep the current thread + ClearEditorState(); + } + + // The thread we leave keeps running; its canvas indices are now stale. + // Its draft (input text and images) stays with it. + Thread& prev = Active(); + prev.liveThinkingIdx = -1; + prev.draft = input_->GetValue(); + prev.draftImages = std::move(pendingImages_); + active_ = idx; + Thread& t = Active(); + input_->SetValue(t.draft); + pendingImages_ = std::move(t.draftImages); + t.draft.clear(); + t.draftImages.clear(); + RebuildImageRow(); + activeCwd_ = t.cwd; + store_.SetLastActive(t.cwd, t.id); RestoreCanvasMaybeDeferred(); + canvas_->SetThinking(t.streaming); + UpdateQueueUI(); + // The prompt of a turn that failed in the background goes back into the + // input, unless the user is already typing something else there. + if (!t.failedPrompt.empty() && input_->IsEmpty()) + input_->SetValue(wxString::FromUTF8(t.failedPrompt)); + t.failedPrompt.clear(); + t.unseen = false; RefreshSessionChoice(); - PopulateEditorTree(); - SetupFsWatcher(); + if (otherFolder) { + PopulateEditorTree(); + SetupFsWatcher(); + } return true; } -void ChatFrame::PersistActive() { - PERF_SCOPE("PersistActive"); - if (activeCwd_.empty()) return; - +void ChatFrame::Persist(Thread& t) { + PERF_SCOPE("Persist"); // Snapshot everything the worker needs up front. The worker must not - // read history_/activeCwd_ while the GUI thread keeps mutating them. - std::string cwd = activeCwd_; - std::vector msgs = history_; - std::string ts = SessionStore::NowIso(); + // read the thread while the GUI thread keeps mutating it. + t.lastUsed = SessionStore::NowIso(); + const SessionStore::Entry entry{ + .id = t.id, .cwd = t.cwd, .lastUsed = t.lastUsed, + .title = t.title, .parent = t.parent, + }; + std::vector msgs = t.history; // The index update is tiny and touches entries_/sessions.json - keep it on // the GUI thread so SessionStore stays single-threaded. The heavy parts // (session-file JSON dump + write, and the FTS5 reindex) run on the worker. - store_.UpdateIndex(cwd, ts); + store_.UpdateIndex(entry); // Serialize with any previous persist so we don't reuse the std::thread // slot while it's still joinable. Normally it finished long ago, so this - // join is instant; it only blocks if the user switches sessions rapidly. + // join is instant; it only blocks if several threads finish at once. if (persistWorker_.joinable()) persistWorker_.join(); - persistWorker_ = std::thread([this, cwd, msgs = std::move(msgs), ts]() { - store_.WriteSessionFile(cwd, msgs, ts); - if (memory_.IsOpen()) { - memory_.RebuildSession(SessionStore::IdForCwd(cwd), cwd, msgs, ts); - } + persistWorker_ = std::thread([this, entry, msgs = std::move(msgs)]() { + store_.WriteSessionFile(entry, msgs); + if (memory_.IsOpen()) + memory_.RebuildSession(entry.id, entry.cwd, entry.title, entry.parent, + msgs, entry.lastUsed); }); } -void ChatFrame::SeedSystemPrompt() { +std::string ChatFrame::SystemPrompt(Thread& t) { + t.lastAgentsContent = LoadAgentsInstructions(t.cwd); std::string content = BaseSystemPrompt(); - std::string agents = LoadAgentsInstructions(activeCwd_); - if (!agents.empty()) content += "\n\n" + agents; - history_.push_back({{"role", "system"}, {"content", std::move(content)}}); + if (!t.lastAgentsContent.empty()) content += "\n\n" + t.lastAgentsContent; + if (std::string folder = FolderContext(t); !folder.empty()) + content += "\n\n" + folder; + if (!t.parent.empty()) content += kSubagentNote; + return content; } -void ChatFrame::RefreshSystemPromptAgents() { - // Rebuild history_[0]'s content from the static base + current AGENTS.md. - // This re-injects project instructions into an EXISTING session whose - // system message was seeded before AGENTS.md existed (or changed). - if (history_.empty()) return; - auto& m = history_[0]; +void ChatFrame::SeedSystemPrompt(Thread& t) { + t.history.push_back({{"role", "system"}, {"content", SystemPrompt(t)}}); +} + +void ChatFrame::RefreshSystemPrompt(Thread& t) { + if (t.history.empty()) return; + auto& m = t.history[0]; if (!m.is_object() || m.value("role", std::string{}) != "system") return; - std::string agents = LoadAgentsInstructions(activeCwd_); - lastAgentsContent_ = agents; - std::string content = BaseSystemPrompt(); - if (!agents.empty()) content += "\n\n" + agents; - m["content"] = std::move(content); + m["content"] = SystemPrompt(t); +} + +std::vector ChatFrame::FolderThreads(const std::string& cwd) const { + std::vector out; + for (const Thread& t : threads_) { + if (t.cwd == cwd && t.parent.empty() && !t.title.empty()) + out.push_back({t.id, t.title, t.lastUsed}); + } + for (const auto& e : store_.List()) { + if (e.cwd == cwd && e.parent.empty() && !e.title.empty() + && std::ranges::find(threads_, e.id, &Thread::id) == threads_.end()) + out.push_back({e.id, e.title, e.lastUsed}); + } + std::ranges::stable_sort(out, std::greater{}, &FolderThread::lastUsed); + return out; +} + +std::string ChatFrame::FolderContext(const Thread& t) const { + // Titles and days only: a list that changes on every turn would keep + // changing the cached prompt prefix. + constexpr int kMaxListed = 10; + std::string list; + int listed = 0; + for (const FolderThread& o : FolderThreads(t.cwd)) { + if (o.id == t.id) continue; + if (listed++ == kMaxListed) break; + list += "\n- " + o.title + " (last used " + o.lastUsed.substr(0, 10) + ")"; + } + std::string out; + if (std::string brief = store_.LoadBrief(t.cwd); !brief.empty()) + out = "Project brief for " + t.cwd + " (written by a model from the " + "README and file list, may be out of date):\n" + brief; + if (!list.empty()) { + if (!out.empty()) out += "\n\n"; + out += "Other threads in this folder: separate conversations whose " + "history isn't available here; grit_history_search with scope " + "\"folder\" finds what was said and done in them." + list; + } + if (const std::string repo = FindGitRoot(t.cwd); !repo.empty() && t.parent.empty()) { + if (!out.empty()) out += "\n\n"; + out += SharedCheckoutNote(repo, store_.WorktreesDir()); + } + return out; +} + +bool ChatFrame::Started(const Thread& t) { + return t.streaming || std::ranges::any_of(t.history, IsUserTurn); +} + +bool ChatFrame::WriteProjectBrief(const std::string& cwd) { + auto job = std::ranges::find(briefJobs_, cwd, &BriefJob::cwd); + if (job == briefJobs_.end()) { + briefJobs_.emplace_back().cwd = cwd; + job = briefJobs_.end() - 1; + } + if (job->running) return false; + std::vector titles; + for (const FolderThread& f : FolderThreads(cwd)) titles.push_back(f.title); + const ModelRoute route = RouteForIndex(BriefModelIndex(currentModelIndex_, remoteModels_), + remoteModels_); + const BriefModel model{ + .url = route.url, + .model = route.model, + .apiKey = route.needsApiKey + ? Preferences::GetApiKey(route.provider).utf8_string() + : std::string(), + .deepseek = route.needsApiKey, + .claudeExe = route.claudeCli ? FindClaudeExecutable() : std::string(), + .label = ModelLabelForId(route.model), + }; + job->writer = model.label; + job->running = true; + if (route.claudeCli && model.claudeExe.empty()) { + CallAfter([this, cwd] { OnBriefWritten(cwd, {}, "Claude Code isn't installed"); }); + return true; + } + job->run = WriteBrief(this, model, cwd, + BriefMaterial(cwd, LoadAgentsInstructions(cwd), titles), + [this, cwd](std::string brief, std::string error) { + OnBriefWritten(cwd, std::move(brief), std::move(error)); + }); + return true; +} + +void ChatFrame::OnBriefWritten(const std::string& cwd, std::string brief, std::string error) { + auto job = std::ranges::find(briefJobs_, cwd, &BriefJob::cwd); + if (job == briefJobs_.end()) return; + job->running = false; + if (error.empty() && !store_.SaveBrief(cwd, brief)) error = "it could not be saved"; + // Threads with no turn yet start with it; the others get it when they + // are next opened, like an AGENTS.md change. + if (error.empty()) + for (Thread& t : threads_) + if (t.cwd == cwd && !Started(t)) RefreshSystemPrompt(t); + Thread& a = Active(); + if (a.cwd != cwd) return; + if (!Started(a)) RestoreCanvasFromHistory(); // "Writing…" becomes the brief + if (!error.empty()) + canvas_->AddBlock(NoticeBlock( + wxString::FromUTF8("\xE2\x9A\xA0 ") + job->writer + " couldn't write the project " + "brief: " + wxString::FromUTF8(error) + ". Threads here start without one; the " + "next new thread tries again.")); + else if (Started(a)) + canvas_->AddBlock(NoticeBlock("The project brief for this folder is written; new " + "threads here start with it.")); +} + +void ChatFrame::AddBriefNotice(const Thread& t) { + if (!t.parent.empty()) return; + auto job = std::ranges::find(briefJobs_, t.cwd, &BriefJob::cwd); + if (job != briefJobs_.end() && job->running) { + canvas_->AddBlock(NoticeBlock( + "Writing a project brief for this folder with " + job->writer + ", from its " + "README, AGENTS.md, file names and thread titles. New threads here start " + "with it" + wxString::FromUTF8("\xE2\x80\xA6"))); + return; + } + const std::string brief = store_.LoadBrief(t.cwd); + if (brief.empty()) return; + canvas_->AddBlock(NoticeBlock("This thread starts with the folder's project brief:")); + MdStream md([this](Block b) { canvas_->AddBlock(std::move(b)); }); + md.Feed(wxString::FromUTF8(brief)); + md.Flush(); } void ChatFrame::RestoreCanvasFromHistory() { PERF_SCOPE("RestoreCanvasFromHistory"); + Thread& t = Active(); + canvas_->Clear(); canvas_->BeginBatch(); + if (!t.parent.empty()) + canvas_->AddBlock(ThreadLinkBlock("Subagent thread (view only), started by ", + t.parent, ThreadLabelOf(t.parent))); ModelSwitchTracker modelTracker; + // Display-only blocks go back where they were added (see localBlocks). + size_t local = 0; + auto emitLocal = [&](size_t upTo) { + for (; local < t.localBlocks.size() && t.localBlocks[local].after <= upTo; ++local) + canvas_->AddBlock(t.localBlocks[local].block); + }; // Walk the message list and re-emit blocks. Tool call/result pairs are // stitched back together: an assistant message's tool_calls are matched // against the immediately-following "tool" messages by tool_call_id. - for (size_t i = 0; i < history_.size(); ++i) { - const auto& m = history_[i]; + for (size_t i = 0; i < t.history.size(); ++i) { + emitLocal(i); + const auto& m = t.history[i]; // Compacted messages are still part of the durable transcript. // They are hidden from the MODEL view (BuildModelView), but MUST be // rendered here so the user sees the full session, not just the @@ -2376,7 +2899,7 @@ void ChatFrame::RestoreCanvasFromHistory() { if (m.contains("images") && m["images"].is_array()) { for (const auto& img : m["images"]) { if (!img.is_object()) continue; - EmitImageBlock(img.value("sha256", std::string{}), + EmitImageBlock(t, img.value("sha256", std::string{}), img.value("mime", "image/png"), img.value("name", std::string{})); } @@ -2390,7 +2913,7 @@ void ChatFrame::RestoreCanvasFromHistory() { if (m.contains("reasoning_content") && m["reasoning_content"].is_string()) { std::string reasoning = m["reasoning_content"].get(); if (!reasoning.empty()) { - RenderThinkingBlock(wxString::FromUTF8(reasoning)); + RenderThinkingBlock(t, wxString::FromUTF8(reasoning)); } } // 1. Render content (if any) through the markdown stream. @@ -2416,8 +2939,8 @@ void ChatFrame::RestoreCanvasFromHistory() { // Find the matching tool result. It should be in the next // few messages with role=tool and matching tool_call_id. std::string result; - for (size_t j = i + 1; j < history_.size(); ++j) { - const auto& r = history_[j]; + for (size_t j = i + 1; j < t.history.size(); ++j) { + const auto& r = t.history[j]; if (r.value("role", std::string{}) != "tool") continue; if (r.value("tool_call_id", std::string{}) != id) continue; if (r.contains("content") && r["content"].is_string()) { @@ -2425,7 +2948,7 @@ void ChatFrame::RestoreCanvasFromHistory() { } break; } - RenderToolBlock(name, args, result); + RenderToolBlock(t, name, args, result, StringField(tc, "thread")); } } continue; @@ -2434,9 +2957,30 @@ void ChatFrame::RestoreCanvasFromHistory() { // role == "system" or "tool": skipped (system not visible; tool was // consumed above by the matching assistant pairing). } + + // The in-flight turn: what has streamed so far but isn't in history yet, + // in the order it streamed. A fresh markdown stream, fed the partial + // text, carries the rest of the reply on this canvas. + t.liveThinkingIdx = -1; + emitLocal(SIZE_MAX); + if (!Started(t)) AddBriefNotice(t); + // A turn that failed in the background: its notice, seen now. + if (t.failureNotice) { + canvas_->AddBlock(std::move(*t.failureNotice)); + t.failureNotice.reset(); + } + if (t.streaming) { + if (t.thinkingEmitted) RenderThinkingBlock(t, wxString::FromUTF8(t.activeReasoning)); + else UpdateLiveThinking(t); + if (t.mdStream) { + t.mdStream = MakeMdStream(t); + t.mdStream->Feed(wxString::FromUTF8(t.activeAssistantText)); + } + } canvas_->EndBatch(); } + void ChatFrame::OnModelChoice(wxCommandEvent& evt) { int sel = evt.GetSelection(); if (sel < 0) return; @@ -2539,13 +3083,24 @@ void ChatFrame::OnModelContextMenu(wxContextMenuEvent&) { PopupMenu(&menu); } +void ChatFrame::StopPlay(PlayRun& run) { + if (auto tok = run.token) { + tok->cancelled.store(true); +#ifndef _WIN32 + int pgid = tok->activePgid.load(); + if (pgid > 0) ::kill(-pgid, SIGTERM); +#endif + } + if (run.worker.joinable()) run.worker.join(); +} + void ChatFrame::OnPlay(wxCommandEvent&) { - if (streaming_) return; + if (Active().streaming) return; auto cfg = RunConfigStore::Get(activeCwd_); if (cfg) { // Direct execution path — no model inference, just run the stored // command. Display-only: the user prompt block and tool result go - // on the canvas but are NOT appended to history_. The Play button + // on the canvas but are NOT appended to history. The Play button // is a local UX affordance; injecting fake tool_call/tool_result // messages into the API conversation breaks subsequent requests // when the command is long-running (e.g. a dev server) — the API @@ -2556,24 +3111,21 @@ void ChatFrame::OnPlay(wxCommandEvent&) { userBlock.rawText = userMsg; userBlock.visibleText = userMsg; userBlock.runs.push_back({userMsg, false, false, false, {}}); - canvas_->AddBlock(std::move(userBlock)); + AddLocalBlock(Active(), std::move(userBlock)); // Run the command on a background thread. Use a dedicated playWorker_ // so a long-running dev server doesn't block the tool dispatch worker // — otherwise the model can't execute tool calls while the server runs. - auto token = std::make_shared(); - // If a previous Play run is still alive (e.g. a dev server), cancel - // it so join() returns quickly instead of blocking the GUI forever. - if (auto old = currentPlayToken_) { - old->cancelled.store(true); -#ifndef _WIN32 - int pgid = old->activePgid.load(); - if (pgid > 0) ::kill(-pgid, SIGTERM); -#endif + auto run = std::ranges::find(plays_, activeCwd_, &PlayRun::cwd); + if (run == plays_.end()) { + plays_.emplace_back().cwd = activeCwd_; + run = plays_.end() - 1; } - if (playWorker_.joinable()) playWorker_.join(); - currentPlayToken_ = token; - playWorker_ = std::thread([this, cmd = cfg->command, token, cwd = activeCwd_]() { + StopPlay(*run); // this folder's previous run, e.g. a dev server + auto token = std::make_shared(); + run->token = token; + run->worker = std::thread([this, cmd = cfg->command, token, cwd = activeCwd_, + id = Active().id]() { // Wrap the stored command with a cd to the project directory so // relative paths work regardless of the process's current cwd. // This is the key fix for the Play button: the model stores @@ -2596,11 +3148,13 @@ void ChatFrame::OnPlay(wxCommandEvent&) { std::string wrapped = "cd " + qcwd + " && " + cmd; #endif std::string result = ToolBashDirect(wrapped.c_str(), token.get()); - CallAfter([this, result, cmd]() { + CallAfter([this, result, cmd, cwd, id, token]() { if (destroying_.load()) return; - currentPlayToken_.reset(); - // Canvas-only display — no history_ modification. - RenderToolBlock("bash", cmd, result); + if (auto r = std::ranges::find(plays_, cwd, &PlayRun::cwd); + r != plays_.end() && r->token == token) + r->token.reset(); + // Display-only, in the thread Play was pressed in. + if (Thread* t = Find(id)) AddLocalBlock(*t, ToolBlock("bash", cmd, result)); }); }); } else { @@ -2641,7 +3195,8 @@ void ChatFrame::OnPlay(wxCommandEvent&) { // The canvas shows only the visible prompt; the model also gets the // hidden instructions. StartTurn routes it to whichever model is // selected, Claude included. - StartTurn(visible, {}, hidden); + StartTurn(Active(), visible, {}, hidden); + } } void ChatFrame::OnSettings(wxCommandEvent&) { @@ -2653,9 +3208,12 @@ void ChatFrame::OnSettings(wxCommandEvent&) { } void ChatFrame::OnCanvasLink(wxCommandEvent& e) { + wxString threadId; if (e.GetString() == "gritcode://settings") { wxCommandEvent ev; OnSettings(ev); + } else if (e.GetString().StartsWith("gritcode://thread/", &threadId)) { + SelectThread(threadId.utf8_string()); } } @@ -2671,9 +3229,50 @@ void ChatFrame::OnHamburger(wxCommandEvent&) { importPanel_->Show(); splitter_->SplitVertically(importPanel_, innerSplitter_, kImportPaneWidth); } + if (ThreadsShown()) UpdateThreadPane(FolderRows()); + else threadPaneTimer_.Stop(); SyncPanelSizing(centerW, tiled); } +void ChatFrame::ShowLeftView(bool threads) { + loadView_->Show(!threads); + threadsView_->Show(threads); + newThreadBtn_->Show(threads); + PaintLeftTabs(); + importPanel_->Layout(); + Preferences::SetLeftPaneThreads(threads); + if (ThreadsShown()) UpdateThreadPane(FolderRows()); + else threadPaneTimer_.Stop(); +} + +void ChatFrame::PaintLeftTabs() { + const wxSize kIconSize(20, 20); + const wxColour on = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT); + const wxColour off = wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT); + const bool threads = threadsView_->IsShown(); + sessionTab_->SetBitmap(LoadThemedSvgIcon("session.svg", kIconSize, threads ? off : on)); + threadsTab_->SetBitmap(LoadThemedSvgIcon("threads.svg", kIconSize, threads ? on : off)); + newThreadBtn_->SetBitmap(LoadThemedSvgIcon("plus.svg", kIconSize, on)); +} + +bool ChatFrame::ThreadsShown() const { + return splitter_->IsSplit() && threadsView_->IsShown(); +} + +void ChatFrame::OnThreadListContextMenu(wxContextMenuEvent& e) { + const int n = e.GetPosition() == wxDefaultPosition + ? threadList_->GetSelection() + : threadList_->HitTest(threadList_->ScreenToClient(e.GetPosition())); + if (n == wxNOT_FOUND) return; + const std::string id = threadList_->Rows()[n].id; + const Thread* t = Find(id); + if (!t || !t->streaming) return; + wxMenu menu; + menu.Append(wxID_STOP, "Stop"); + if (threadList_->GetPopupMenuSelectionFromUser(menu) != wxID_STOP) return; + if (Thread* still = Find(id); still && still->streaming) RequestCancel(*still); +} + void ChatFrame::OnEditorToggle(wxCommandEvent&) { const bool tiled = hyprland::IsTiled().value_or(false); int centerW = mainPanel_->GetSize().x; @@ -2705,7 +3304,8 @@ void ChatFrame::OnEditorToggle(wxCommandEvent&) { int importW = splitter_->IsSplit() ? kImportPaneWidth + splitter_->GetSashSize() : 0; int editorW = editorPaneW_ + innerSplitter_->GetSashSize(); - SetClientSize(wxSize(centerW + importW + editorW, GetClientSize().y)); + SetClientSize(wxSize(centerW + importW + editorW, + GetClientSize().y)); Layout(); splitter_->UpdateSize(); innerSplitter_->UpdateSize(); @@ -3023,10 +3623,13 @@ void ChatFrame::OnTreeRefreshTimer(wxTimerEvent&) { RescanFsWatcher(); CheckEditorFileChangedOnDisk(); - // AGENTS.md may have been added or edited since the session loaded — - // re-inject it into the system prompt only when its content changed. + // AGENTS.md may have been added or edited since the threads loaded — + // re-inject it into their system prompts only when its content changed. std::string agents = LoadAgentsInstructions(activeCwd_); - if (agents != lastAgentsContent_) RefreshSystemPromptAgents(); + for (Thread& t : threads_) { + if (t.cwd == activeCwd_ && agents != t.lastAgentsContent) + RefreshSystemPrompt(t); + } } void ChatFrame::OnTreeRefresh(wxCommandEvent&) { @@ -3635,8 +4238,9 @@ void ChatFrame::OnExport(wxCommandEvent&) { if (path.find(".gritsession") == std::string::npos) path += ".gritsession"; + const Thread& t = Active(); nlohmann::json j; - j["version"] = HistoryHasImages(history_) ? 2 : 1; + j["version"] = HistoryHasImages(t.history) ? 2 : 1; j["exportedAt"] = []() { auto now = std::chrono::system_clock::now(); auto t = std::chrono::system_clock::to_time_t(now); @@ -3644,12 +4248,12 @@ void ChatFrame::OnExport(wxCommandEvent&) { std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t)); return std::string(buf); }(); - j["messages"] = history_; + j["messages"] = t.history; std::string err; bool ok = false; - if (HistoryHasImages(history_)) { - ok = ExportToZip(path, j, CollectImageRefs(history_), err); + if (HistoryHasImages(t.history)) { + ok = ExportToZip(path, j, CollectImageRefs(t.history), err); } else { std::ofstream f(path, std::ios::binary | std::ios::trunc); ok = (bool)f; @@ -3666,7 +4270,7 @@ void ChatFrame::OnExport(wxCommandEvent&) { wxOK | wxICON_ERROR, this); return; } - wxMessageBox(FormatU8("Session exported ({} messages).", history_.size()), + wxMessageBox(FormatU8("Session exported ({} messages).", t.history.size()), "Export", wxOK | wxICON_INFORMATION, this); } @@ -3808,7 +4412,8 @@ void ChatFrame::ShowImportDialog() { refLabel_->SetLabel(wxString::FromUTF8("Referenced Session: ") + displayName + wxString::FromUTF8(" \xe2\x80\x94 ")); changeBtn_->Show(); - importPanel_->Layout(); + ShowLeftView(false); + loadView_->Layout(); // Split to show the import panel on the left. if (!splitter_->IsSplit()) { @@ -3825,12 +4430,15 @@ void ChatFrame::OnSend(wxCommandEvent&) { text.Trim().Trim(false); if (text.IsEmpty() && pendingImages_.empty()) return; + Thread& t = Active(); + if (!t.parent.empty()) return; // subagent threads are view-only + // Agent busy: append to the queue (the Send button is "Add" while busy). // Reaching kMaxQueue_ disables the button — defensive check for a // synthesized event from MCP / Enter key. - if (streaming_) { - if (pendingQueue_.size() >= kMaxQueue_) return; - EnqueueMessage(text); + if (t.streaming) { + if (t.pendingQueue.size() >= kMaxQueue_) return; + EnqueueMessage(t, text); input_->Clear(); return; } @@ -3839,19 +4447,21 @@ void ChatFrame::OnSend(wxCommandEvent&) { std::vector images = std::move(pendingImages_); pendingImages_.clear(); RebuildImageRow(); - StartTurn(text, std::move(images)); + StartTurn(t, text, std::move(images)); } -void ChatFrame::StartTurn(const wxString& userText, +void ChatFrame::StartTurn(Thread& t, const wxString& userText, std::vector images, const std::string& hiddenInstructions) { - overflowRetried_ = false; - turnModelIndex_ = currentModelIndex_; - const ModelRoute route = RouteForIndex(currentModelIndex_, remoteModels_); + t.overflowRetried = false; + t.stopRequested = false; + t.failure.clear(); + t.turnModelIndex = ModelIndexFor(t); + const ModelRoute route = RouteForIndex(t.turnModelIndex, remoteModels_); const std::string modelId = route.model; - RenderModelSwitchNotice(modelId); + RenderModelSwitchNotice(t, modelId); // Render the user prompt block (skipped for image-only messages). - if (!userText.IsEmpty()) { + if (!userText.IsEmpty() && Shown(t)) { Block ub; ub.type = BlockType::UserPrompt; ub.rawText = userText; @@ -3862,7 +4472,7 @@ void ChatFrame::StartTurn(const wxString& userText, canvas_->AddBlock(std::move(ub)); } for (const auto& pi : images) { - EmitImageBlock(pi.hash, pi.mime, pi.name); + EmitImageBlock(t, pi.hash, pi.mime, pi.name); } // "model" records where the turn was sent, so a restored or exported @@ -3872,6 +4482,30 @@ void ChatFrame::StartTurn(const wxString& userText, {"content", userText.ToStdString(wxConvUTF8) + hiddenInstructions}, {"model", modelId}, }; + // Other top-level threads working in the same checkout right now. The + // model gets this note after the message (the system prompt says what to + // do about it), only when it changed since the previous message. + if (t.parent.empty()) { + const std::string checkout = CheckoutOf(t.cwd); + std::string others; + for (const Thread& o : threads_) { + if (&o == &t || !o.streaming || !o.parent.empty() || CheckoutOf(o.cwd) != checkout) + continue; + others += (others.empty() ? "\"" : ", \"") + + ThreadLabel(o.id, o.cwd, o.title).utf8_string() + "\""; + } + // The last note this thread's model got, and so what it believes now. + auto noted = [](const nlohmann::json& m) { + return IsUserTurn(m) && !StringField(m, "note").empty(); + }; + auto last = std::ranges::find_if(t.history.rbegin(), t.history.rend(), noted); + const std::string given = + last == t.history.rend() ? std::string() : StringField(*last, "note"); + const std::string none = "No other thread is working in " + checkout + " now."; + const std::string note = + others.empty() ? none : "Also working in " + checkout + " right now: " + others + "."; + if (note != given && !(note == none && given.empty())) userMsg["note"] = note; + } if (!images.empty()) { nlohmann::json arr = nlohmann::json::array(); for (const auto& pi : images) { @@ -3881,23 +4515,32 @@ void ChatFrame::StartTurn(const wxString& userText, } userMsg["images"] = std::move(arr); } - history_.push_back(std::move(userMsg)); + t.history.push_back(std::move(userMsg)); + t.turnPrompt = userText.utf8_string(); + if (t.title.empty()) t.title = DefaultTitle(t.turnPrompt); - streaming_ = true; - canvas_->SetThinking(true); - UpdateQueueUI(); // Send becomes "Add", chip row stays visible if non-empty. - toolIter_ = 0; + t.streaming = true; + t.toolIter = 0; + if (Shown(t)) { + canvas_->SetThinking(true); + UpdateQueueUI(); // Send becomes "Add", chip row stays visible if non-empty. + } - StartCompletion(); + StartCompletion(t); + RefreshSessionChoice(); // marks the thread as running } -void ChatFrame::EnqueueMessage(const wxString& text) { +int ChatFrame::ModelIndexFor(const Thread& t) const { + return t.parent.empty() ? currentModelIndex_ : t.turnModelIndex; +} + +void ChatFrame::EnqueueMessage(Thread& t, const wxString& text) { QueuedMessage q; q.text = text.ToStdString(wxConvUTF8); q.images = std::move(pendingImages_); pendingImages_.clear(); RebuildImageRow(); - pendingQueue_.push_back(std::move(q)); + t.pendingQueue.push_back(std::move(q)); UpdateQueueUI(); } @@ -3926,28 +4569,32 @@ void ChatFrame::OpenDebugWindow() { #endif } -nlohmann::json ChatFrame::BuildModelView() const { +nlohmann::json ChatFrame::BuildModelView(const Thread& t) const { nlohmann::json view = nlohmann::json::array(); // System prompt is always the head. - if (!history_.empty() && history_[0].is_object() - && history_[0].value("role", std::string{}) == "system") { - view.push_back(history_[0]); + if (!t.history.empty() && t.history[0].is_object() + && t.history[0].value("role", std::string{}) == "system") { + view.push_back(t.history[0]); } // Summary checkpoints become the new head after compaction. - for (const auto& m : history_) { + for (const auto& m : t.history) { if (!m.is_object() || m.value("compacted", false)) continue; if (m.value("isSummary", false)) view.push_back(m); } // Non-hidden, non-summary, non-system tail. nlohmann::json tail = nlohmann::json::array(); - for (const auto& m : history_) { + for (const auto& m : t.history) { if (!m.is_object() || m.value("compacted", false)) continue; if (m.value("isSummary", false)) continue; if (m.value("role", std::string{}) == "system") continue; tail.push_back(m); + if (std::string note = StringField(m, "note"); + !note.empty() && tail.back()["content"].is_string()) + tail.back()["content"] = tail.back()["content"].get() + + "\n\n[gritcode: " + note + "]"; } PruneToolOutputs(tail); @@ -4017,67 +4664,64 @@ Block ChatFrame::MakeImageBlock(const std::string& hash, const std::string& mime return b; } -void ChatFrame::EmitImageBlock(const std::string& hash, const std::string& mime, +void ChatFrame::EmitImageBlock(Thread& t, const std::string& hash, const std::string& mime, const std::string& name) { - canvas_->AddBlock(MakeImageBlock(hash, mime, name)); + if (Shown(t)) canvas_->AddBlock(MakeImageBlock(hash, mime, name)); +} + +std::unique_ptr ChatFrame::MakeMdStream(Thread& t) { + return std::make_unique([this, &t](Block b) { + if (Shown(t)) canvas_->AddBlock(std::move(b)); + }); } -void ChatFrame::StartCompletion() { + +void ChatFrame::StartCompletion(Thread& t) { // A Claude turn never takes the HTTP path: Claude Code runs its own agent // loop and manages (and compacts) its own context, so gritcode's // compaction doesn't apply either. - const ModelRoute turnRoute = RouteForIndex(turnModelIndex_, remoteModels_); + const ModelRoute turnRoute = RouteForIndex(t.turnModelIndex, remoteModels_); if (turnRoute.claudeCli) { - claudeModel_ = turnRoute.model; - claudeRetriedFresh_ = false; - StartClaudeTurn(); + t.claudeModel = turnRoute.model; + t.claudeRetriedFresh = false; + StartClaudeTurn(t); return; } // Compaction preflight. If the rendered request would overflow the // model's context window, we summarize the head of history first via // a separate streaming request; the summary completion path will // call DoSendActualRequest itself once history has been rewritten. - // Skipped while compacting_ is true so a chained ApplyCompaction + // Skipped while t.compacting is true so a chained ApplyCompaction // doesn't re-evaluate the same condition. - if (!compacting_ && MaybeCompactThenSend()) return; - DoSendActualRequest(); + if (!t.compacting && MaybeCompactThenSend(t)) return; + DoSendActualRequest(t); } -void ChatFrame::DoSendActualRequest() { - // Re-sync the process cwd to the active session right before every model - // turn. The bash/list_directory/read_file/etc. tools fork in the process - // cwd, so this must match activeCwd_ or the agent ends up operating in the - // previous session's folder. Cheap (one syscall) and covers any call site - // that forgets to chdir after changing activeCwd_. - ChdirToCwd(activeCwd_); - +void ChatFrame::DoSendActualRequest(Thread& t) { // Reset per-completion stream state. - activeAssistantText_.clear(); - activeReasoning_.clear(); - activeToolCalls_.clear(); - thinkingEmitted_ = false; - liveThinkingIdx_ = -1; - finishReason_.clear(); - sseBuf_.clear(); - mdStream_ = std::make_unique([this](Block b) { - canvas_->AddBlock(std::move(b)); - }); + t.activeAssistantText.clear(); + t.activeReasoning.clear(); + t.activeToolCalls.clear(); + t.thinkingEmitted = false; + t.liveThinkingIdx = -1; + t.finishReason.clear(); + t.sseBuf.clear(); + t.mdStream = MakeMdStream(t); ModelRoute route = - CompletionRoute(currentModelIndex_, turnModelIndex_, remoteModels_); - requestModel_ = route.model; + CompletionRoute(ModelIndexFor(t), t.turnModelIndex, remoteModels_); + t.requestModel = route.model; - // Build an outbound copy of history with the active cwd appended to the + // Build an outbound copy of history with the thread's cwd appended to the // system prompt. Done per-request rather than baked into stored history - // so the cwd stays current if the user switches sessions mid-conversation - // and doesn't accumulate across turns. - nlohmann::json messages = BuildModelView(); + // so it doesn't accumulate across turns. + nlohmann::json messages = BuildModelView(t); if (!messages.empty() && messages[0].is_object() && messages[0].value("role", std::string{}) == "system") { std::string base = messages[0].value("content", std::string{}); std::string extra; - if (!activeCwd_.empty()) - extra += "\n\nWorking directory: " + activeCwd_; + if (!t.cwd.empty()) + extra += "\n\nWorking directory: " + t.cwd; // When the user disabled Grit History tools, explicitly countermand // the baked "use grit_history_search" system-prompt guidance so the // agent neither attempts the (now-absent) tools nor leans on local @@ -4103,6 +4747,10 @@ void ChatFrame::DoSendActualRequest() { if (!m.is_object()) continue; m.erase("model"); m.erase("claudeSessionId"); + if (auto calls = m.find("tool_calls"); calls != m.end() && calls->is_array()) { + for (auto& tc : *calls) + if (tc.is_object()) tc.erase("thread"); + } } // Defensive: drop consecutive same-role user/assistant messages. Both @@ -4150,7 +4798,7 @@ void ChatFrame::DoSendActualRequest() { // `content`) so one message can't dominate the tail. The call already ran // (its result follows it in the history), so the model only needs the // shape of the arguments to stay coherent, not the full payload. The - // durable history_ is untouched — this only affects the outbound request. + // durable history is untouched — this only affects the outbound request. for (auto& m : messages) { if (!m.is_object() || m.value("role", std::string{}) != "assistant" || !m.contains("tool_calls") || !m["tool_calls"].is_array()) { @@ -4208,10 +4856,10 @@ void ChatFrame::DoSendActualRequest() { if (route.needsApiKey) { apiKey = Preferences::GetApiKey(route.provider); if (apiKey.IsEmpty()) { - RenderErrorBlock( + // A failed turn like any other: the unsent message is rolled back. + HandleCompletion(t, "No API key configured for the selected model. " "Open Settings (gear icon) to add one."); - FinalizeTurn(true); return; } } @@ -4229,14 +4877,15 @@ void ChatFrame::DoSendActualRequest() { int avail = route.contextWindow - promptTokens - 8000; if (avail < 8000) avail = 8000; if (maxTokens > avail) maxTokens = avail; - requestMaxTokens_ = maxTokens; + t.requestMaxTokens = maxTokens; nlohmann::json req; req["model"] = route.model; req["stream"] = true; req["max_tokens"] = maxTokens; req["messages"] = std::move(messages); - req["tools"] = GetToolDefinitions(Preferences::GetEnableGritHistory()); + req["tools"] = GetToolDefinitions(Preferences::GetEnableGritHistory(), + /*subagent=*/!t.parent.empty()); // DeepSeek thinking effort ("high" is the API default; "max" is opt-in // from Settings). if (route.provider == Preferences::Provider::DeepSeek && route.needsApiKey) { @@ -4281,113 +4930,107 @@ void ChatFrame::DoSendActualRequest() { // connection in a recoverable amount of time. spec.idleTimeoutSeconds = 60; - request_ = StreamingWebRequest( + ++t.gen; + t.request = StreamingWebRequest( this, std::move(spec), - [this](std::string_view chunk) { OnStreamData(chunk); }, - [this](WebResponse resp) { OnStreamDone(std::move(resp)); }); -} - -void ChatFrame::OnStreamData(std::string_view chunk) { - if (chunk.empty()) return; - sseBuf_.append(chunk.data(), chunk.size()); - - size_t pos; - while ((pos = sseBuf_.find("\n\n")) != std::string::npos) { - std::string event = sseBuf_.substr(0, pos); - sseBuf_.erase(0, pos + 2); - - std::istringstream stream(event); - std::string line; - while (std::getline(stream, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (line.rfind("data: ", 0) != 0) continue; - std::string payload = line.substr(6); - if (payload == "[DONE]") continue; - try { - auto j = nlohmann::json::parse(payload); - if (!j.contains("choices") || j["choices"].empty()) continue; - const auto& choice = j["choices"][0]; - if (choice.contains("finish_reason") - && choice["finish_reason"].is_string()) - finishReason_ = choice["finish_reason"].get(); - if (!choice.contains("delta")) continue; - const auto& delta = choice["delta"]; - - // ---- text content ---- - // DeepSeek streams reasoning bytes in `reasoning_content` and - // visible bytes in `content` (potentially in the same delta). - // Other providers may use `reasoning` or `reasoning_text`. We - // capture reasoning into activeReasoning_ so it can be round- - // tripped on the next request — DeepSeek requires this on every - // assistant message in history or returns 400. - for (const char* f : {"reasoning_content", "reasoning", "reasoning_text"}) { - if (delta.contains(f) && delta[f].is_string()) { - activeReasoning_ += delta[f].get(); - UpdateLiveThinking(); - break; - } + [this, id = t.id, gen = t.gen](std::string_view chunk) { + if (Thread* t = Find(id, gen)) OnStreamData(*t, chunk); + }, + [this, id = t.id, gen = t.gen](WebResponse resp) { + if (Thread* t = Find(id, gen)) OnStreamDone(*t, std::move(resp)); + }); +} + +void ChatFrame::OnStreamData(Thread& t, std::string_view chunk, bool streamEnded) { + if (chunk.empty() && !streamEnded) return; + for (const std::string& payload : TakeSseEvents(t.sseBuf, chunk, streamEnded)) { + if (payload == "[DONE]") continue; + try { + auto j = nlohmann::json::parse(payload); + if (!j.contains("choices") || j["choices"].empty()) continue; + const auto& choice = j["choices"][0]; + if (choice.contains("finish_reason") + && choice["finish_reason"].is_string()) + t.finishReason = choice["finish_reason"].get(); + if (!choice.contains("delta")) continue; + const auto& delta = choice["delta"]; + + // ---- text content ---- + // DeepSeek streams reasoning bytes in `reasoning_content` and + // visible bytes in `content` (potentially in the same delta). + // Other providers may use `reasoning` or `reasoning_text`. We + // capture reasoning into t.activeReasoning so it can be round- + // tripped on the next request — DeepSeek requires this on every + // assistant message in history or returns 400. + for (const char* f : {"reasoning_content", "reasoning", "reasoning_text"}) { + if (delta.contains(f) && delta[f].is_string()) { + t.activeReasoning += delta[f].get(); + UpdateLiveThinking(t); + break; } - if (delta.contains("content") && delta["content"].is_string()) { - std::string content = delta["content"].get(); - if (!content.empty()) { - // First non-reasoning byte of this round: emit the - // thinking block now so it lands before any content - // block the markdown stream is about to push out. - EmitPendingThinking(); - activeAssistantText_ += content; - if (mdStream_) mdStream_->Feed(wxString::FromUTF8(content)); - } + } + if (delta.contains("content") && delta["content"].is_string()) { + std::string content = delta["content"].get(); + if (!content.empty()) { + // First non-reasoning byte of this round: emit the + // thinking block now so it lands before any content + // block the markdown stream is about to push out. + EmitPendingThinking(t); + t.activeAssistantText += content; + if (t.mdStream) t.mdStream->Feed(wxString::FromUTF8(content)); } + } - // ---- tool_calls ---- - if (delta.contains("tool_calls") && delta["tool_calls"].is_array() - && !delta["tool_calls"].empty()) { - // Same as the content case — render thinking before any - // tool block lands. RenderToolBlock fires later in - // HandleCompletion, but emitting here keeps the order - // consistent regardless of when the model interleaves - // tool_calls and content. - EmitPendingThinking(); - for (const auto& tc : delta["tool_calls"]) { - int idx = tc.value("index", 0); - if ((int)activeToolCalls_.size() <= idx) - activeToolCalls_.resize(idx + 1); - auto& cur = activeToolCalls_[idx]; - if (tc.contains("id") && tc["id"].is_string()) - cur.id = tc["id"].get(); - if (tc.contains("function") && tc["function"].is_object()) { - const auto& fn = tc["function"]; - if (fn.contains("name") && fn["name"].is_string()) - cur.name = fn["name"].get(); - if (fn.contains("arguments") && fn["arguments"].is_string()) - cur.args += fn["arguments"].get(); - } + // ---- tool_calls ---- + if (delta.contains("tool_calls") && delta["tool_calls"].is_array() + && !delta["tool_calls"].empty()) { + // Same as the content case — render thinking before any + // tool block lands. RenderToolBlock fires later in + // HandleCompletion, but emitting here keeps the order + // consistent regardless of when the model interleaves + // tool_calls and content. + EmitPendingThinking(t); + for (const auto& tc : delta["tool_calls"]) { + int idx = tc.value("index", 0); + if ((int)t.activeToolCalls.size() <= idx) + t.activeToolCalls.resize(idx + 1); + auto& cur = t.activeToolCalls[idx]; + if (tc.contains("id") && tc["id"].is_string()) + cur.id = tc["id"].get(); + if (tc.contains("function") && tc["function"].is_object()) { + const auto& fn = tc["function"]; + if (fn.contains("name") && fn["name"].is_string()) + cur.name = fn["name"].get(); + if (fn.contains("arguments") && fn["arguments"].is_string()) + cur.args += fn["arguments"].get(); } } - } catch (...) { - // Ignore malformed events (keepalives, partial JSON, etc.). } + } catch (...) { + // Ignore malformed events (keepalives, partial JSON, etc.). } } } -void ChatFrame::OnStreamDone(WebResponse resp) { +void ChatFrame::OnStreamDone(Thread& t, WebResponse resp) { // The user closed the window while a stream was in flight. The Cancel // already short-circuited the worker; just complete the close now. if (quitRequested_) { Close(); return; } + // A last event the server didn't end with a blank line. + if (resp.ok) OnStreamData(t, {}, /*streamEnded=*/true); // Flush markdown parser before deciding what to do next — any pending // paragraph/code block needs to land before tool blocks or the next turn. - if (mdStream_) mdStream_->Flush(); - mdStream_.reset(); + if (t.mdStream) t.mdStream->Flush(); + t.mdStream.reset(); if (!resp.ok && resp.error == "cancelled") { - if (!streaming_) return; - RenderErrorBlock("Cancelled."); - FinalizeTurn(true); + if (!t.streaming) return; + RenderErrorBlock(t, "Cancelled."); + FinalizeTurn(t, true); return; } @@ -4395,26 +5038,26 @@ void ChatFrame::OnStreamDone(WebResponse resp) { wxString detail = FormatU8( "Authentication failed (HTTP {}). Check your API key in Settings.", resp.status); - wxString body = ExtractErrorBody(); + wxString body = ExtractErrorBody(t); if (!body.IsEmpty() && body.length() < 400) { detail += "\n\n" + body; } - HandleCompletion(detail); + HandleCompletion(t, detail); return; } if (!resp.ok) { // Layer 3: context-overflow recovery. Force one compaction and // retry; if it overflows again, fall through to the error path. - if (resp.status == 400 && !overflowRetried_) { + if (resp.status == 400 && !t.overflowRetried) { std::string errBody = - ExtractErrorBody().ToStdString(wxConvUTF8); + ExtractErrorBody(t).ToStdString(wxConvUTF8); bool isOverflow = errBody.find("context") != std::string::npos && (errBody.find("token") != std::string::npos || errBody.find("maximum") != std::string::npos); - if (isOverflow && ForceCompactForOverflow()) { - overflowRetried_ = true; + if (isOverflow && ForceCompactForOverflow(t)) { + t.overflowRetried = true; return; } } @@ -4424,9 +5067,9 @@ void ChatFrame::OnStreamDone(WebResponse resp) { // explanation + a link to Settings instead of a raw curl error, so it // doesn't read as a gritcode failure. ModelRoute route = - CompletionRoute(currentModelIndex_, turnModelIndex_, remoteModels_); + CompletionRoute(ModelIndexFor(t), t.turnModelIndex, remoteModels_); if (!route.needsApiKey && resp.networkError) { - HandleCompletion(wxString(), /*freeModelStall=*/true); + HandleCompletion(t, wxString(), /*freeModelStall=*/true); return; } @@ -4436,21 +5079,21 @@ void ChatFrame::OnStreamDone(WebResponse resp) { } else { detail = "Error: " + wxString::FromUTF8(resp.error); } - wxString body = ExtractErrorBody(); + wxString body = ExtractErrorBody(t); if (!body.IsEmpty()) detail += "\n\n" + body; - HandleCompletion(detail); + HandleCompletion(t, detail); return; } - HandleCompletion(wxString()); + HandleCompletion(t, wxString()); } -wxString ChatFrame::ExtractErrorBody() const { - if (sseBuf_.empty()) return wxString(); +wxString ChatFrame::ExtractErrorBody(const Thread& t) { + if (t.sseBuf.empty()) return wxString(); // Try to pretty-print a JSON error like // {"error":{"message":"...","type":"...","code":"..."}}. try { - auto j = nlohmann::json::parse(sseBuf_); + auto j = nlohmann::json::parse(t.sseBuf); if (j.is_object() && j.contains("error")) { const auto& err = j["error"]; if (err.is_object() && err.contains("message") @@ -4461,63 +5104,69 @@ wxString ChatFrame::ExtractErrorBody() const { } } catch (...) {} // Fall back to the raw body, capped so we don't dump megabytes. - std::string body = sseBuf_; + std::string body = t.sseBuf; if (body.size() > 800) body = body.substr(0, 800) + "…"; return wxString::FromUTF8(body); } -void ChatFrame::HandleCompletion(const wxString& errorIfFailed, +void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, bool freeModelStall) { // Pure-reasoning response (model returned reasoning_content but no content // or tool_calls): emit the thinking block here so the user sees what the // model produced. No-op if a prior content/tool delta already emitted it. - EmitPendingThinking(); + EmitPendingThinking(t); if (!errorIfFailed.IsEmpty() || freeModelStall) { - if (freeModelStall) RenderFreeModelStallNotice(); - else RenderErrorBlock(errorIfFailed); - // Roll back the failed turn from history_: drop any tool messages plus + // A subagent's turn is never retried: its history stays, showing the + // task and how far it got. + const bool rollBack = t.parent.empty(); + ShowTurnFailure(t, freeModelStall ? FreeModelStallNotice() + : NoticeBlock(errorIfFailed), + /*promptRolledBack=*/rollBack); + // Roll back the failed turn from t.history: drop any tool messages plus // the trailing user message that triggered this turn. Without this, // every retry would carry a growing tail of orphan user messages and // every subsequent request would 400 (consecutive same-role rule). // The canvas keeps the rendered prompt + error blocks so the user sees // what happened — only the model's view of history is rewound. - while (!history_.empty()) { - std::string role = history_.back().value("role", std::string{}); + while (rollBack && !t.history.empty()) { + std::string role = t.history.back().value("role", std::string{}); if (role == "tool" || role == "assistant") { - history_.pop_back(); + t.history.pop_back(); continue; } if (role == "user") { - history_.pop_back(); + t.history.pop_back(); } break; } - FinalizeTurn(true); + FinalizeTurn(t, true); return; } // The reply stopped at max_tokens. With tool calls the truncated // arguments already produce a retry error for the model; for a plain // reply, tell the user instead of ending silently. - if (finishReason_ == "length" && activeToolCalls_.empty()) { - RenderErrorBlock(FormatU8( + if (t.finishReason == "length" && t.activeToolCalls.empty()) { + RenderErrorBlock(t, FormatU8( "The reply was cut off: it reached the output limit ({} tokens). " "Say \"continue\" to let the model pick up where it stopped.", - requestMaxTokens_)); + t.requestMaxTokens)); } - if (activeToolCalls_.empty()) { + if (t.activeToolCalls.empty()) { // Plain assistant message — record and finish. - if (!activeAssistantText_.empty() || !activeReasoning_.empty()) { + if (!t.activeAssistantText.empty() || !t.activeReasoning.empty()) { nlohmann::json msg = {{"role", "assistant"}, - {"content", activeAssistantText_}, - {"model", requestModel_}}; - if (!activeReasoning_.empty()) - msg["reasoning_content"] = activeReasoning_; - history_.push_back(std::move(msg)); + {"content", std::move(t.activeAssistantText)}, + {"model", t.requestModel}}; + if (!t.activeReasoning.empty()) + msg["reasoning_content"] = std::move(t.activeReasoning); + t.history.push_back(std::move(msg)); } - FinalizeTurn(); + t.activeAssistantText.clear(); + t.activeReasoning.clear(); + FinalizeTurn(t); return; } @@ -4525,22 +5174,25 @@ void ChatFrame::HandleCompletion(const wxString& errorIfFailed, // may have produced both content and tool_calls in one turn, so include // both. content can be null per OpenAI spec when only tool_calls exist. nlohmann::json assistantMsg = {{"role", "assistant"}, - {"model", requestModel_}}; - if (activeAssistantText_.empty()) + {"model", t.requestModel}}; + if (t.activeAssistantText.empty()) assistantMsg["content"] = nullptr; else - assistantMsg["content"] = activeAssistantText_; - if (!activeReasoning_.empty()) - assistantMsg["reasoning_content"] = activeReasoning_; + assistantMsg["content"] = t.activeAssistantText; + if (!t.activeReasoning.empty()) + assistantMsg["reasoning_content"] = t.activeReasoning; assistantMsg["tool_calls"] = nlohmann::json::array(); - for (const auto& tc : activeToolCalls_) { + for (const auto& tc : t.activeToolCalls) { assistantMsg["tool_calls"].push_back({ {"id", tc.id}, {"type", "function"}, {"function", {{"name", tc.name}, {"arguments", tc.args}}}, }); } - history_.push_back(std::move(assistantMsg)); + t.history.push_back(std::move(assistantMsg)); + // Now in history: nothing of this round is in flight while tools run. + t.activeAssistantText.clear(); + t.activeReasoning.clear(); // Dispatch each tool on a worker thread so blocking I/O (bash popen, // file reads, web fetches) never freezes the UI. Pre-parse arguments on @@ -4554,8 +5206,12 @@ void ChatFrame::HandleCompletion(const wxString& errorIfFailed, std::string parseError; // non-empty: skip dispatch, surface this as the result }; auto jobs = std::make_shared>(); - jobs->reserve(activeToolCalls_.size()); - for (const auto& tc : activeToolCalls_) { + jobs->reserve(t.activeToolCalls.size()); + // `task` calls run as child threads instead, once the worker is started. + std::vector tasks; + t.toolBatch.clear(); + for (const auto& tc : t.activeToolCalls) { + t.toolBatch.push_back({tc.id, tc.name, tc.args, std::string()}); ToolJob job; job.id = tc.id; job.name = tc.name; @@ -4582,22 +5238,42 @@ void ChatFrame::HandleCompletion(const wxString& errorIfFailed, "Retry, splitting large content into smaller calls if needed."; } } + if (job.parseError.empty() && job.name == "task" && t.parent.empty()) { + if (!StringField(job.argsParsed, "prompt").empty()) { + tasks.push_back(std::move(job)); + continue; + } + job.parseError = "Error: task needs a prompt with the complete instructions."; + } + if (job.parseError.empty() && !t.parent.empty() && IsWriteTool(job.name)) { + job.parseError = "Error: " + job.name + " isn't available: subagents are " + "read-only. Describe the change in your report instead."; + } jobs->push_back(std::move(job)); } auto token = std::make_shared(); - currentToolToken_ = token; + t.currentToolToken = token; + t.toolWorkerRunning = true; // OnToolBatchDone only runs after the previous worker exits, so by the // time we dispatch a new batch the prior thread is finished — join() is // a non-blocking handoff that lets us reuse the std::thread slot without // detaching (detach makes destructor cleanup impossible). - if (toolWorker_.joinable()) toolWorker_.join(); + if (t.toolWorker.joinable()) t.toolWorker.join(); bool enableGrit = Preferences::GetEnableGritHistory(); - toolWorker_ = std::thread([this, jobs, token, cwd = activeCwd_, - enableGrit]() { - auto results = std::make_shared>(); - results->reserve(jobs->size()); + // History search leaves out this thread (it's in context), unless + // compaction summarised part of it away: then its old turns are worth + // finding again. + const bool compacted = std::ranges::any_of(t.history, [](const nlohmann::json& m) { + return m.is_object() && m.value("isSummary", false); + }); + ++t.gen; + t.toolWorker = std::thread([this, jobs, token, cwd = t.cwd, enableGrit, + searchExclude = compacted ? std::string() : t.id, + id = t.id, gen = t.gen]() { + std::vector results; + results.reserve(jobs->size()); for (auto& job : *jobs) { std::string r; if (token->cancelled.load()) { @@ -4606,22 +5282,30 @@ void ChatFrame::HandleCompletion(const wxString& errorIfFailed, r = std::move(job.parseError); } else { r = DispatchTool(job.name, job.argsParsed, token.get(), - &memory_, cwd, enableGrit); + &memory_, cwd, enableGrit, searchExclude); } - results->push_back({std::move(job.id), std::move(job.name), - std::move(job.argsJson), std::move(r)}); + results.push_back({std::move(job.id), std::move(job.name), + std::move(job.argsJson), std::move(r)}); } - auto* ev = new wxThreadEvent(wxEVT_TOOL_BATCH_DONE); - ev->SetPayload(results); - wxQueueEvent(this, ev); + CallAfter([this, id, gen, results = std::move(results)]() mutable { + Thread* t = Find(id, gen); + if (!t) return; + for (auto& r : results) { + auto e = std::ranges::find(t->toolBatch, r.id, &ToolBatchEntry::id); + if (e != t->toolBatch.end()) e->result = std::move(r.result); + } + t->toolWorkerRunning = false; + OnToolBatchDone(*t); + }); }); -} -void ChatFrame::OnToolBatchDone(wxThreadEvent& e) { - auto results = e.GetPayload>>(); - bool wasCancelled = currentToolToken_ && currentToolToken_->cancelled.load(); - currentToolToken_.reset(); + for (const ToolJob& task : tasks) { + StartSubagent(t, task.id, StringField(task.argsParsed, "description"), + StringField(task.argsParsed, "prompt")); + } +} +void ChatFrame::OnToolBatchDone(Thread& t) { // User closed the window mid-batch — OnClose vetoed and is waiting for us // to complete this phase. Re-fire Close so the second pass through OnClose // sees an idle frame and lets the destruction proceed. @@ -4629,63 +5313,85 @@ void ChatFrame::OnToolBatchDone(wxThreadEvent& e) { Close(); return; } + if (t.toolWorkerRunning || HasRunningChild(t)) return; - if (!results) return; + bool wasCancelled = t.currentToolToken && t.currentToolToken->cancelled.load(); + t.currentToolToken.reset(); - for (auto& r : *results) { - RenderToolBlock(r.name, r.argsJson, r.result); - history_.push_back({ + for (auto& r : t.toolBatch) { + const Thread* child = ChildFor(t, r.id); + RenderToolBlock(t, r.name, r.argsJson, r.result, child ? child->id : std::string()); + t.history.push_back({ {"role", "tool"}, {"tool_call_id", r.id}, {"name", r.name}, - {"content", r.result}, + {"content", std::move(r.result)}, }); } + t.toolBatch.clear(); if (wasCancelled) { // User hit Escape during tool execution. The assistant's tool_calls // message and any tool results (real or "[cancelled]") are already in // history, so the next user turn replays cleanly. Stop the loop here // and surface a marker block. - RenderErrorBlock("Cancelled."); - FinalizeTurn(true); + RenderErrorBlock(t, "Cancelled."); + FinalizeTurn(t, true); return; } - toolIter_++; + t.toolIter++; // Continue the conversation with another completion request. - StartCompletion(); + StartCompletion(t); } -void ChatFrame::FinalizeTurn(bool wasCancelledOrError) { +void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { + if (!Shown(t) && t.parent.empty() && !t.stopRequested) t.unseen = true; + // Claude Code subagents still running when the run ends (cancelled, + // failed, or their end never reported) end with it. A tool batch waits + // for its `task` subagents, so those are done by now. + for (Thread& c : threads_) { + if (c.parent == t.id && c.streaming) + FinishClaudeSubagent(c, "The subagent was stopped: its parent's turn ended first."); + } // A cancelled/failed round may never reach EmitPendingThinking; don't // leave its Thinking block live with stale text. - if (liveThinkingIdx_ >= 0) { - canvas_->UpdateThinkingBlock(liveThinkingIdx_, - wxString::FromUTF8(activeReasoning_), false); - liveThinkingIdx_ = -1; - } - canvas_->SetThinking(false); - streaming_ = false; - activeAssistantText_.clear(); - activeToolCalls_.clear(); - PersistActive(); - // Title may have changed (first user message defines it) — refresh the - // dropdown so the new label shows up. + if (t.liveThinkingIdx >= 0 && Shown(t)) { + canvas_->UpdateThinkingBlock(t.liveThinkingIdx, + wxString::FromUTF8(t.activeReasoning), false); + } + t.liveThinkingIdx = -1; + if (Shown(t)) canvas_->SetThinking(false); + t.streaming = false; + t.activeAssistantText.clear(); + t.activeReasoning.clear(); + t.activeToolCalls.clear(); + t.mdStream.reset(); + // A subagent cancelled in the background keeps saying so until viewed, + // as a failure does (cancelling drew "Cancelled." only if shown). + if (!t.parent.empty() && wasCancelledOrError && t.failure.empty() && !Shown(t)) + t.failureNotice = NoticeBlock("Cancelled."); + Persist(t); + // Title and running mark changed — refresh the dropdown so the new + // label shows up. RefreshSessionChoice(); + if (!t.parentToolId.empty()) ReportToParent(t, wasCancelledOrError); + // Natural completion: auto-dispatch the next queued message so back-to- // back turns flow without a click. DispatchNextQueued -> StartTurn flips - // streaming_ back on so UpdateQueueUI never observes the (idle, queue + // t.streaming back on so UpdateQueueUI never observes the (idle, queue // non-empty) intermediate state and the input stays visible the whole // time. Error/cancel: drop into idle-queue mode (Continue / Clear) if // the queue is non-empty, so the user decides what to do. - if (!wasCancelledOrError && !pendingQueue_.empty()) { - DispatchNextQueued(); + if (!wasCancelledOrError && !t.pendingQueue.empty()) { + DispatchNextQueued(t); return; } + if (!Shown(t)) return; UpdateQueueUI(); + // Put the cursor back in the input, but only if the user is already here: // on wxGTK, SetFocus() on an inactive window calls gtk_window_present(), // and Wayland compositors (Hyprland) then switch workspaces and pull the @@ -4693,48 +5399,133 @@ void ChatFrame::FinalizeTurn(bool wasCancelledOrError) { if (input_->IsShown() && IsActive()) input_->SetFocus(); } -void ChatFrame::RenderToolBlock(const std::string& name, - const std::string& argsJson, - const std::string& result) { - // Compact the args JSON for display: parse + dump to drop whitespace. - std::string displayArgs = argsJson; - try { - if (!argsJson.empty()) { - displayArgs = nlohmann::json::parse(argsJson).dump( - -1, ' ', false, nlohmann::json::error_handler_t::replace); - } - } catch (...) { /* show raw */ } +ChatFrame::Thread* ChatFrame::ChildFor(const Thread& t, const std::string& toolId) { + auto it = std::ranges::find_if(threads_, [&](const Thread& c) { + return c.parent == t.id && c.parentToolId == toolId; + }); + return it != threads_.end() ? &*it : nullptr; +} - std::string preview = result; - constexpr size_t kPreviewCap = 4000; - if (preview.size() > kPreviewCap) { - preview.resize(kPreviewCap); - preview += "\n... [preview truncated; full output sent to model]"; +bool ChatFrame::HasRunningChild(const Thread& t) const { + return std::ranges::any_of(threads_, [&](const Thread& c) { + return c.parent == t.id && c.streaming; + }); +} + +ChatFrame::Thread& ChatFrame::OpenChild(Thread& parent, const std::string& toolId, + const std::string& description) { + Thread& c = OpenThread(SessionStore::NewThreadId(), parent.cwd); + c.parent = parent.id; + c.parentToolId = toolId; + c.title = DefaultTitle(description.empty() ? std::string("Subagent") : description); + if (Shown(parent)) + canvas_->AddBlock(ThreadLinkBlock("Subagent started: ", c.id, ThreadLabelOf(c.id))); + return c; +} + +void ChatFrame::StartSubagent(Thread& parent, const std::string& toolId, + const std::string& description, const std::string& prompt) { + Thread& c = OpenChild(parent, toolId, description); + c.turnModelIndex = SubagentModelIndex(parent.requestModel, parent.turnModelIndex, + remoteModels_); + RefreshSystemPrompt(c); // now with the subagent note + // Recorded on the call (the parent's last message), so the restored + // parent links to the child. + for (auto& tc : parent.history.back()["tool_calls"]) { + if (StringField(tc, "id") == toolId) tc["thread"] = c.id; + } + StartTurn(c, wxString::FromUTF8(prompt)); +} + +void ChatFrame::ReportToParent(Thread& child, bool failed) { + Thread* p = Find(child.parent); + if (!p) return; + // A Claude Code subagent's result arrives in its parent's stream instead. + auto e = std::ranges::find(p->toolBatch, child.parentToolId, &ToolBatchEntry::id); + if (e == p->toolBatch.end()) return; + if (failed) { + e->result = child.failure.empty() + ? "Error: the subagent was cancelled." + : "Error: the subagent failed: " + child.failure; + } else { + const auto& last = child.history.back(); + const std::string report = StringField(last, "role") == "assistant" + ? StringField(last, "content") : std::string(); + e->result = report.empty() ? "The subagent finished without a report." + : Truncated(report, 30'000); + } + OnToolBatchDone(*p); +} + +ChatFrame::Thread& ChatFrame::StartClaudeSubagent(Thread& parent, const std::string& toolId, + const nlohmann::json& input) { + Thread& c = OpenChild(parent, toolId, StringField(input, "description")); + // The subagent's own events arrive in the parent's stream; this thread + // assembles them like a turn of its own (see HandleClaudeEvent). + c.claudeModel = parent.claudeModel; + c.history.push_back({{"role", "user"}, + {"content", StringField(input, "prompt")}, + {"model", c.claudeModel}}); + c.streaming = true; + c.mdStream = MakeMdStream(c); + RefreshSessionChoice(); + return c; +} + +void ChatFrame::FinishClaudeSubagent(Thread& c, const std::string& failure) { + if (!c.streaming) return; + if (c.mdStream) c.mdStream->Flush(); + c.mdStream.reset(); + EmitPendingThinking(c); + FlushClaudePendingMessage(c); + if (!failure.empty()) { + ShowTurnFailure(c, NoticeBlock(wxString::FromUTF8(failure)), false); + // The parent shows it as well, and records it with its Agent call's + // result (which, for a background subagent, only said it started). + if (Thread* p = Find(c.parent)) { + for (auto& m : p->history) { + if (StringField(m, "role") == "tool" + && StringField(m, "tool_call_id") == c.parentToolId) + m["content"] = StringField(m, "content") + "\n\n" + failure; + } + if (Shown(*p)) + canvas_->AddBlock(ThreadLinkBlock(wxString::FromUTF8(failure + " Open: "), + c.id, ThreadLabelOf(c.id))); + } } + FinalizeTurn(c, !failure.empty()); +} - Block b; - b.type = BlockType::ToolCall; - b.toolName = wxString::FromUTF8(name); - b.toolArgs = wxString::FromUTF8(displayArgs); - b.toolResult = wxString::FromUTF8(preview); - b.toolExpanded = false; - // visibleText layout: - // [0, headerLen) "toolName(args)" - // [headerLen, headerLen+2) "\n\n" separator - // [headerLen+2, end) toolResult - // Header chars are selectable in the rendered header strip; body chars - // are selectable only when expanded. Selecting across a collapsed block - // (drag from another block, through this one) still grabs the full - // visibleText, so Ctrl+C captures both the header and the hidden body. - b.visibleText = b.toolName + "(" + b.toolArgs + ")\n\n" + b.toolResult; - canvas_->AddBlock(std::move(b)); +void ChatFrame::RenderToolBlock(Thread& t, const std::string& name, + const std::string& argsJson, + const std::string& result, + const std::string& childId) { + if (!Shown(t)) return; + canvas_->AddBlock(ToolBlock(name, argsJson, result)); + if (!childId.empty()) + canvas_->AddBlock(ThreadLinkBlock("Subagent thread: ", childId, ThreadLabelOf(childId))); +} + +void ChatFrame::RenderErrorBlock(Thread& t, const wxString& msg) { + if (Shown(t)) canvas_->AddBlock(NoticeBlock(msg)); } -void ChatFrame::RenderErrorBlock(const wxString& msg) { - canvas_->AddBlock(NoticeBlock(msg)); +void ChatFrame::AddLocalBlock(Thread& t, Block b) { + if (Shown(t)) canvas_->AddBlock(b); + t.localBlocks.push_back({t.history.size(), std::move(b)}); } -void ChatFrame::RenderFreeModelStallNotice() { +void ChatFrame::ShowTurnFailure(Thread& t, Block notice, bool promptRolledBack) { + t.failure = notice.visibleText.utf8_string(); + if (Shown(t)) { + canvas_->AddBlock(std::move(notice)); + return; + } + t.failureNotice = std::move(notice); + t.failedPrompt = promptRolledBack ? t.turnPrompt : std::string(); +} + +Block ChatFrame::FreeModelStallNotice() { wxString text = "The free model stopped responding. Free models can lose their " "connection from time to time. For a reliable experience, set up " @@ -4756,12 +5547,11 @@ void ChatFrame::RenderFreeModelStallNotice() { r2.bold = true; r2.link = "gritcode://settings"; b.runs.push_back(r2); - - canvas_->AddBlock(std::move(b)); + return b; } -void ChatFrame::RenderThinkingBlock(const wxString& text) { - if (text.IsEmpty()) return; +void ChatFrame::RenderThinkingBlock(Thread& t, const wxString& text) { + if (text.IsEmpty() || !Shown(t)) return; Block b; b.type = BlockType::Thinking; b.rawText = text; @@ -4770,75 +5560,78 @@ void ChatFrame::RenderThinkingBlock(const wxString& text) { canvas_->AddBlock(std::move(b)); } -void ChatFrame::EmitPendingThinking() { - if (thinkingEmitted_) return; - thinkingEmitted_ = true; - if (liveThinkingIdx_ >= 0) { +void ChatFrame::EmitPendingThinking(Thread& t) { + if (t.thinkingEmitted) return; + t.thinkingEmitted = true; + // liveThinkingIdx is only set while the thread is shown. + if (t.liveThinkingIdx >= 0) { // The block is already on the canvas: give it the complete reasoning // and end live mode, after which it behaves like any Thinking block. - canvas_->UpdateThinkingBlock(liveThinkingIdx_, - wxString::FromUTF8(activeReasoning_), false); - liveThinkingIdx_ = -1; + canvas_->UpdateThinkingBlock(t.liveThinkingIdx, + wxString::FromUTF8(t.activeReasoning), false); + t.liveThinkingIdx = -1; return; } - if (activeReasoning_.empty()) return; - RenderThinkingBlock(wxString::FromUTF8(activeReasoning_)); + if (t.activeReasoning.empty()) return; + RenderThinkingBlock(t, wxString::FromUTF8(t.activeReasoning)); } -void ChatFrame::UpdateLiveThinking() { - if (thinkingEmitted_ || activeReasoning_.empty()) return; +void ChatFrame::UpdateLiveThinking(Thread& t) { + if (t.thinkingEmitted || t.activeReasoning.empty() || !Shown(t)) return; + const auto now = std::chrono::steady_clock::now(); - if (liveThinkingIdx_ < 0) { + if (t.liveThinkingIdx < 0) { Block b; b.type = BlockType::Thinking; - b.rawText = wxString::FromUTF8(activeReasoning_); + b.rawText = wxString::FromUTF8(t.activeReasoning); b.visibleText = b.rawText; b.toolExpanded = false; // collapsed, like every Thinking block b.thinkingLive = true; canvas_->AddBlock(std::move(b)); - liveThinkingIdx_ = (int)canvas_->Blocks().size() - 1; - liveThinkingLastUpdate_ = now; - liveThinkingLayoutCost_ = {}; + t.liveThinkingIdx = (int)canvas_->Blocks().size() - 1; + t.liveThinkingLastUpdate = now; + t.liveThinkingLayoutCost = {}; return; } // A collapsed block only draws its header, so its text can wait until the // round finalizes it. When expanded, refresh it in chunks, not per token. const auto& blocks = canvas_->Blocks(); - if (liveThinkingIdx_ >= (int)blocks.size() - || !blocks[liveThinkingIdx_].toolExpanded) + if (t.liveThinkingIdx >= (int)blocks.size() + || !blocks[t.liveThinkingIdx].toolExpanded) return; // Each refresh re-wraps the whole reasoning, which gets slower as it // grows, so the interval grows with it: at least 500 ms, and 20x the last // re-layout, keeping layout under ~5% of the UI thread on long runs. const auto interval = std::max( - std::chrono::milliseconds(500), liveThinkingLayoutCost_ * 20); - if (now - liveThinkingLastUpdate_ < interval) return; - canvas_->UpdateThinkingBlock(liveThinkingIdx_, - wxString::FromUTF8(activeReasoning_), true); - liveThinkingLastUpdate_ = std::chrono::steady_clock::now(); - liveThinkingLayoutCost_ = liveThinkingLastUpdate_ - now; + std::chrono::milliseconds(500), t.liveThinkingLayoutCost * 20); + if (now - t.liveThinkingLastUpdate < interval) return; + canvas_->UpdateThinkingBlock(t.liveThinkingIdx, + wxString::FromUTF8(t.activeReasoning), true); + t.liveThinkingLastUpdate = std::chrono::steady_clock::now(); + t.liveThinkingLayoutCost = t.liveThinkingLastUpdate - now; } -void ChatFrame::DispatchNextQueued() { - if (pendingQueue_.empty()) return; - QueuedMessage next = std::move(pendingQueue_.front()); - pendingQueue_.erase(pendingQueue_.begin()); - StartTurn(wxString::FromUTF8(next.text), std::move(next.images)); +void ChatFrame::DispatchNextQueued(Thread& t) { + if (t.pendingQueue.empty()) return; + QueuedMessage next = std::move(t.pendingQueue.front()); + t.pendingQueue.erase(t.pendingQueue.begin()); + StartTurn(t, wxString::FromUTF8(next.text), std::move(next.images)); } void ChatFrame::OnContinueQueue(wxCommandEvent&) { - DispatchNextQueued(); + DispatchNextQueued(Active()); } void ChatFrame::OnClearQueue(wxCommandEvent&) { - pendingQueue_.clear(); + Active().pendingQueue.clear(); UpdateQueueUI(); if (input_->IsShown()) input_->SetFocus(); } void ChatFrame::UpdateQueueUI() { - const bool busy = streaming_; - const bool hasQueue = !pendingQueue_.empty(); + const Thread& t = Active(); + const bool busy = t.streaming; + const bool hasQueue = !t.pendingQueue.empty(); const bool idleQueueMode = !busy && hasQueue; // Normal vs idle-queue: input + Send swap with Continue + Clear. @@ -4849,14 +5642,18 @@ void ChatFrame::UpdateQueueUI() { if (busy) { sendBtn_->SetLabel("Add"); - sendBtn_->Enable(pendingQueue_.size() < kMaxQueue_); + sendBtn_->Enable(t.pendingQueue.size() < kMaxQueue_); } else { sendBtn_->SetLabel("Send"); sendBtn_->Enable(true); } + // Subagent threads are view-only. + const bool viewOnly = !t.parent.empty(); + input_->Enable(!viewOnly); + if (viewOnly) sendBtn_->Enable(false); if (idleQueueMode) { continueQueueBtn_->SetLabel( - FormatU8("Continue ({} queued)", pendingQueue_.size())); + FormatU8("Continue ({} queued)", t.pendingQueue.size())); } chipRow_->Show(hasQueue); @@ -4882,8 +5679,9 @@ void ChatFrame::RebuildChips() { wxColour chipBg = shift(winBg, 22); constexpr size_t kMaxChars = 60; - for (size_t i = 0; i < pendingQueue_.size(); ++i) { - const QueuedMessage& q = pendingQueue_[i]; + const Thread& t = Active(); + for (size_t i = 0; i < t.pendingQueue.size(); ++i) { + const QueuedMessage& q = t.pendingQueue[i]; wxString full = wxString::FromUTF8(q.text); wxString label = full; label.Replace("\n", " "); @@ -4932,13 +5730,15 @@ void ChatFrame::RebuildChips() { std::string entry = q.text; close->Bind(wxEVT_LEFT_DOWN, [this, entry](wxMouseEvent&) { CallAfter([this, entry]() { + Thread& t = Active(); auto it = std::find_if( - pendingQueue_.begin(), pendingQueue_.end(), + + t.pendingQueue.begin(), t.pendingQueue.end(), [&](const QueuedMessage& q) { return q.text == entry; }); - if (it == pendingQueue_.end()) return; - pendingQueue_.erase(it); + if (it == t.pendingQueue.end()) return; + t.pendingQueue.erase(it); UpdateQueueUI(); - if (!streaming_ && pendingQueue_.empty() && input_->IsShown()) + if (!t.streaming && t.pendingQueue.empty() && input_->IsShown()) input_->SetFocus(); }); }); @@ -4953,7 +5753,7 @@ void ChatFrame::RebuildChips() { // // Goal: when the rendered history would overflow the model's context window, // replace the oldest portion with a real LLM-generated summary — the same -// strategy opencode uses. Persisting the summary in history_ (rather than +// strategy opencode uses. Persisting the summary in history (rather than // only rewriting the wire request) keeps every subsequent turn within // budget; without that, history would keep growing and we'd re-"compact" // the same head on every send. @@ -4962,31 +5762,31 @@ void ChatFrame::RebuildChips() { // 1. StartCompletion → MaybeCompactThenSend. If history fits the budget, // return false → caller proceeds to DoSendActualRequest. // 2. Over budget: pick the most recent user message as the split point. -// Head = history_[0..split), tail = history_[split..] stays intact +// Head = history[0..split), tail = history[split..] stays intact // (so we never break tool_call / tool_result pairs). -// 3. Fire an async summary request on `request_` with the summary +// 3. Fire an async summary request on the thread's `request` with the summary // callbacks (OnSummaryStreamData / OnSummaryStreamDone). The UI // shows a notice so the extra round-trip doesn't look like a hang. // 4. ApplyCompaction (on the GUI thread, after the summary returns) // replaces the head with a single isSummary user-role message, // persists, and calls DoSendActualRequest to fire the real request. -// 5. historyCompactBaseCount_ is bumped to the post-compaction size so +// 5. historyCompactBaseCount is bumped to the post-compaction size so // MaybeCompactThenSend won't re-compact until enough new messages // have accumulated. // --------------------------------------------------------------------------- -bool ChatFrame::MaybeCompactThenSend() { - int histSize = (int)history_.size(); +bool ChatFrame::MaybeCompactThenSend(Thread& t) { + int histSize = (int)t.history.size(); // Hysteresis: require some growth since last compaction before retrying. - int growth = histSize - historyCompactBaseCount_; - if (growth < 5 && historyCompactBaseCount_ > 0) return false; + int growth = histSize - t.historyCompactBaseCount; + if (growth < 5 && t.historyCompactBaseCount > 0) return false; // Select the token-budget tail: the most recent ~kTailBudgetTokens of // conversation stays verbatim; everything before it is the head we // summarize. This replaces the old "keep 2 user turns" rule (which was // unbounded) with OpenCode's bounded preserveRecentBudget. - int splitIdx = SelectTailSplit(); + int splitIdx = SelectTailSplit(t); // Nothing older than the protected tail to summarize. if (splitIdx <= 1) return false; @@ -4994,23 +5794,23 @@ bool ChatFrame::MaybeCompactThenSend() { // rendered view would exceed the context window minus a reserve. This is // ~contextWindow (≈980K for DeepSeek) instead of the old 150K head budget, // so compaction fires ~7x less often. - nlohmann::json view = BuildModelView(); + nlohmann::json view = BuildModelView(t); int viewTokens = EstimatePromptTokens(view); ModelRoute route = - CompletionRoute(currentModelIndex_, turnModelIndex_, remoteModels_); + CompletionRoute(ModelIndexFor(t), t.turnModelIndex, remoteModels_); int usable = route.contextWindow - kBufferTokens; if (viewTokens < usable) return false; - RunSummaryThenSend(splitIdx); + RunSummaryThenSend(t, splitIdx); return true; } -int ChatFrame::SelectTailSplit() const { - int histSize = (int)history_.size(); +int ChatFrame::SelectTailSplit(const Thread& t) { + int histSize = (int)t.history.size(); int tailTokens = 0; int splitIdx = histSize; for (int i = histSize - 1; i >= 0; --i) { - const auto& m = history_[i]; + const auto& m = t.history[i]; if (!m.is_object()) continue; if (m.value("compacted", false)) continue; // A summary checkpoint is a head boundary: everything older than it @@ -5026,34 +5826,34 @@ int ChatFrame::SelectTailSplit() const { return splitIdx; } -bool ChatFrame::ForceCompactForOverflow() { +bool ChatFrame::ForceCompactForOverflow(Thread& t) { // Keep only the most recent user turn verbatim; summarize everything // before it. This is more aggressive than the normal 2-turn keep. int splitIdx = -1; - for (int i = (int)history_.size() - 1; i >= 0; --i) { - if (!history_[i].is_object()) continue; - if (history_[i].value("compacted", false)) continue; - if (history_[i].value("role", std::string{}) != "user") continue; - if (history_[i].value("isSummary", false)) continue; + for (int i = (int)t.history.size() - 1; i >= 0; --i) { + if (!t.history[i].is_object()) continue; + if (t.history[i].value("compacted", false)) continue; + if (t.history[i].value("role", std::string{}) != "user") continue; + if (t.history[i].value("isSummary", false)) continue; splitIdx = i; break; } if (splitIdx <= 1) return false; - RunSummaryThenSend(splitIdx); + RunSummaryThenSend(t, splitIdx); return true; } -void ChatFrame::RunSummaryThenSend(int splitIdx) { - compacting_ = true; - compactionSplitIdx_ = splitIdx; - compactionHeadCount_ = splitIdx; - summarySseBuf_.clear(); - summaryText_.clear(); +void ChatFrame::RunSummaryThenSend(Thread& t, int splitIdx) { + t.compacting = true; + t.compactionSplitIdx = splitIdx; + t.compactionHeadCount = splitIdx; + t.summarySseBuf.clear(); + t.summaryText.clear(); - RenderErrorBlock(FormatU8( + RenderErrorBlock(t, FormatU8( "📦 Compacting context - summarizing {} older messages before " "continuing. This usually takes a few seconds…", - compactionHeadCount_)); + t.compactionHeadCount)); // Render the head as plain text. We feed the summary model a flat // transcript rather than the raw tool_calls / tool_result structure — @@ -5069,7 +5869,7 @@ void ChatFrame::RunSummaryThenSend(int splitIdx) { headText.reserve(16384); std::string previousSummary; for (int i = 0; i < splitIdx; ++i) { - const auto& m = history_[i]; + const auto& m = t.history[i]; if (!m.is_object()) continue; if (m.value("compacted", false)) continue; // already summarized if (m.value("isSummary", false)) { @@ -5104,7 +5904,7 @@ void ChatFrame::RunSummaryThenSend(int splitIdx) { // Cap the summary-call input so the summary request itself doesn't // overflow. Budget = context window − response budget − prompt overhead. ModelRoute route = - CompletionRoute(currentModelIndex_, turnModelIndex_, remoteModels_); + CompletionRoute(ModelIndexFor(t), t.turnModelIndex, remoteModels_); size_t maxChars = (size_t)(route.contextWindow - 6000) * 4; if (maxChars < 40000) maxChars = 40000; if (headText.size() > maxChars) { @@ -5164,7 +5964,7 @@ void ChatFrame::RunSummaryThenSend(int splitIdx) { // without a summary, then send the actual request. The user // already saw the compaction notice; ApplyCompaction will add // a follow-up explaining the fallback. - ApplyCompaction(false, std::string{}, "no API key configured"); + ApplyCompaction(t, false, std::string{}, "no API key configured"); return; } } @@ -5197,66 +5997,60 @@ void ChatFrame::RunSummaryThenSend(int splitIdx) { } spec.idleTimeoutSeconds = 60; - request_ = StreamingWebRequest( + ++t.gen; + t.request = StreamingWebRequest( this, std::move(spec), - [this](std::string_view chunk) { OnSummaryStreamData(chunk); }, - [this](WebResponse resp) { OnSummaryStreamDone(std::move(resp)); }); -} - -void ChatFrame::OnSummaryStreamData(std::string_view chunk) { - if (chunk.empty()) return; - summarySseBuf_.append(chunk.data(), chunk.size()); - - size_t pos; - while ((pos = summarySseBuf_.find("\n\n")) != std::string::npos) { - std::string event = summarySseBuf_.substr(0, pos); - summarySseBuf_.erase(0, pos + 2); - std::istringstream stream(event); - std::string line; - while (std::getline(stream, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (line.rfind("data: ", 0) != 0) continue; - std::string payload = line.substr(6); - if (payload == "[DONE]") continue; - try { - auto j = nlohmann::json::parse(payload); - if (!j.contains("choices") || j["choices"].empty()) continue; - const auto& choice = j["choices"][0]; - if (!choice.contains("delta")) continue; - const auto& delta = choice["delta"]; - if (delta.contains("content") && delta["content"].is_string()) { - summaryText_ += delta["content"].get(); - } - } catch (...) { - // Tolerate keepalives / partial JSON / malformed events. + [this, id = t.id, gen = t.gen](std::string_view chunk) { + if (Thread* t = Find(id, gen)) OnSummaryStreamData(*t, chunk); + }, + [this, id = t.id, gen = t.gen](WebResponse resp) { + if (Thread* t = Find(id, gen)) OnSummaryStreamDone(*t, std::move(resp)); + }); +} + +void ChatFrame::OnSummaryStreamData(Thread& t, std::string_view chunk, bool streamEnded) { + if (chunk.empty() && !streamEnded) return; + for (const std::string& payload : TakeSseEvents(t.summarySseBuf, chunk, streamEnded)) { + if (payload == "[DONE]") continue; + try { + auto j = nlohmann::json::parse(payload); + if (!j.contains("choices") || j["choices"].empty()) continue; + const auto& choice = j["choices"][0]; + if (!choice.contains("delta")) continue; + const auto& delta = choice["delta"]; + if (delta.contains("content") && delta["content"].is_string()) { + t.summaryText += delta["content"].get(); } + } catch (...) { + // Tolerate keepalives / partial JSON / malformed events. } } } -void ChatFrame::OnSummaryStreamDone(WebResponse resp) { +void ChatFrame::OnSummaryStreamDone(Thread& t, WebResponse resp) { try { if (quitRequested_) { Close(); return; } + if (resp.ok) OnSummaryStreamData(t, {}, /*streamEnded=*/true); // User hit Escape during the summary call — abort the whole turn rather // than dropping the head with no summary and continuing. if (!resp.ok && resp.error == "cancelled") { - compacting_ = false; - compactionSplitIdx_ = -1; - compactionHeadCount_ = 0; - summaryText_.clear(); - summarySseBuf_.clear(); - RenderErrorBlock("Cancelled."); - FinalizeTurn(true); + t.compacting = false; + t.compactionSplitIdx = -1; + t.compactionHeadCount = 0; + t.summaryText.clear(); + t.summarySseBuf.clear(); + RenderErrorBlock(t, "Cancelled."); + FinalizeTurn(t, true); return; } if (!resp.ok) { std::string err = resp.error.empty() ? "HTTP error" : resp.error; - ApplyCompaction(false, std::string{}, err); + ApplyCompaction(t, false, std::string{}, err); return; } // Trim so whitespace-only/empty summaries count as failures and fall // back to the "context dropped" path instead of an empty checkpoint. - std::string summary = summaryText_; + std::string summary = t.summaryText; while (!summary.empty() && (summary.back() == '\n' || summary.back() == '\r' || summary.back() == ' ' || summary.back() == '\t')) summary.pop_back(); @@ -5265,28 +6059,28 @@ void ChatFrame::OnSummaryStreamDone(WebResponse resp) { || summary[lead] == ' ' || summary[lead] == '\t')) ++lead; summary = summary.substr(lead); - ApplyCompaction(!summary.empty(), summary, std::string{}); + ApplyCompaction(t, !summary.empty(), summary, std::string{}); } catch (const std::exception& e) { std::fprintf(stderr, "[OnSummaryStreamDone] exception: %s\n", e.what()); throw; } } -void ChatFrame::ApplyCompaction(bool success, const std::string& summary, +void ChatFrame::ApplyCompaction(Thread& t, bool success, const std::string& summary, const std::string& error) { try { - int splitIdx = compactionSplitIdx_; - int origHeadCount = compactionHeadCount_; - compactionSplitIdx_ = -1; - compactionHeadCount_ = 0; - summaryText_.clear(); - summarySseBuf_.clear(); + int splitIdx = t.compactionSplitIdx; + int origHeadCount = t.compactionHeadCount; + t.compactionSplitIdx = -1; + t.compactionHeadCount = 0; + t.summaryText.clear(); + t.summarySseBuf.clear(); // Defensive: history could have changed shape under us (shouldn't on // the GUI thread, but be safe). - if (splitIdx <= 0 || splitIdx > (int)history_.size()) { - compacting_ = false; - DoSendActualRequest(); + if (splitIdx <= 0 || splitIdx > (int)t.history.size()) { + t.compacting = false; + DoSendActualRequest(t); return; } @@ -5297,18 +6091,18 @@ void ChatFrame::ApplyCompaction(bool success, const std::string& summary, "compacted into this summary to fit the model's context " "window. Treat it as authoritative background for continuing " "the current task.]\n\n" + summary; - RenderErrorBlock(FormatU8( + RenderErrorBlock(t, FormatU8( "📦 Context compacted: {} older messages replaced by a summary.", origHeadCount)); } else { // Fallback — even a failed summary should shrink history, // otherwise the very next request would hit the same overflow. - // historyCompactBaseCount_ still gates re-entry. + // t.historyCompactBaseCount still gates re-entry. summaryBody = "[Prior conversation context was dropped to fit the model's " "context window. Summary unavailable" + (error.empty() ? std::string{} : (": " + error)) + ".]"; - RenderErrorBlock(FormatU8( + RenderErrorBlock(t, FormatU8( "⚠️ Compaction summary failed{} - dropping {} older messages " "without a summary so the next request can fit.", error.empty() ? std::string{} : (" (" + error + ")"), @@ -5320,9 +6114,9 @@ void ChatFrame::ApplyCompaction(bool success, const std::string& summary, // head of the model view. The session file keeps every message. std::string ts = SessionStore::NowIso(); for (int i = 0; i < splitIdx; ++i) { - if (!history_[i].is_object()) continue; - history_[i]["compacted"] = true; - history_[i]["compactedAt"] = ts; + if (!t.history[i].is_object()) continue; + t.history[i]["compacted"] = true; + t.history[i]["compactedAt"] = ts; } nlohmann::json summaryMsg = { @@ -5332,7 +6126,7 @@ void ChatFrame::ApplyCompaction(bool success, const std::string& summary, }; #ifndef NDEBUG - // Log before moving summaryMsg into history_ — a moved-from json is null + // Log before moving summaryMsg into t.history — a moved-from json is null // and reading its content would throw type_error. LogDebug("=== COMPACTION split=" + std::to_string(splitIdx) + " hidden=" + std::to_string(origHeadCount) @@ -5340,13 +6134,13 @@ void ChatFrame::ApplyCompaction(bool success, const std::string& summary, LogDebug(summaryMsg["content"].get()); #endif - history_.push_back(std::move(summaryMsg)); + t.history.push_back(std::move(summaryMsg)); - historyCompactBaseCount_ = (int)history_.size(); - PersistActive(); + t.historyCompactBaseCount = (int)t.history.size(); + Persist(t); - compacting_ = false; - DoSendActualRequest(); + t.compacting = false; + DoSendActualRequest(t); } catch (const std::exception& e) { std::fprintf(stderr, "[ApplyCompaction] exception: %s\n", e.what()); throw; @@ -5362,7 +6156,7 @@ void ChatFrame::ApplyCompaction(bool success, const std::string& summary, // Code runs its own agent loop and tools; gritcode renders its events: // - stream_event text/thinking deltas stream into the canvas, // - "assistant" events carry the finished blocks (text, tool_use), which -// are recorded in history_ as OpenAI-style assistant messages, +// are recorded in history as OpenAI-style assistant messages, // - "user" events carry tool_results, rendered as tool blocks and recorded // as role=tool messages, // - "result" ends the turn (is_error for failures). @@ -5384,17 +6178,17 @@ constexpr size_t kClaudeToolResultMaxChars = 30'000; } // namespace -void ChatFrame::RenderModelSwitchNotice(const std::string& modelId) { +void ChatFrame::RenderModelSwitchNotice(Thread& t, const std::string& modelId) { ModelSwitchTracker tracker; - for (const auto& m : history_) { + for (const auto& m : t.history) { if (IsUserTurn(m)) tracker.Next(m, false); } wxString notice = tracker.Next(nlohmann::json{{"model", modelId}}, false); - if (!notice.IsEmpty()) RenderErrorBlock(notice); + if (!notice.IsEmpty()) RenderErrorBlock(t, notice); } -void ChatFrame::StartClaudeTurn(bool fresh) { - claudeTurnHistoryStart_ = history_.size(); +void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { + t.claudeTurnHistoryStart = t.history.size(); const std::string exe = FindClaudeExecutable(); if (exe.empty()) { @@ -5409,21 +6203,22 @@ void ChatFrame::StartClaudeTurn(bool fresh) { b.runs.push_back(link); b.rawText += linkText; b.visibleText += linkText; - canvas_->AddBlock(std::move(b)); - if (!history_.empty() && IsUserTurn(history_.back())) history_.pop_back(); - FinalizeTurn(true); + const bool rolledBack = !t.history.empty() && IsUserTurn(t.history.back()); + if (rolledBack) t.history.pop_back(); + ShowTurnFailure(t, std::move(b), rolledBack); + FinalizeTurn(t, true); return; } // Resume the latest Claude Code session in this gritcode session. What // other models said after it (or the whole conversation, when there is // no Claude session yet) goes in front of the message as a transcript. - const size_t userIdx = history_.size() - 1; + const size_t userIdx = t.history.size() - 1; std::string resumeId; size_t since = 0; if (!fresh) { for (size_t i = userIdx; i-- > 0;) { - std::string sid = StringField(history_[i], "claudeSessionId"); + std::string sid = StringField(t.history[i], "claudeSessionId"); if (!sid.empty()) { resumeId = sid; since = i + 1; @@ -5433,21 +6228,23 @@ void ChatFrame::StartClaudeTurn(bool fresh) { } std::string text; { - std::string t = HandoverTranscript(history_, since, userIdx, - /*skipClaude=*/!resumeId.empty()); - if (!t.empty()) { + std::string transcript = HandoverTranscript(t.history, since, userIdx, + /*skipClaude=*/!resumeId.empty()); + if (!transcript.empty()) { text = resumeId.empty() ? "[This conversation started in gritcode with another model. " "Transcript so far, for context:]\n\n" : "[Since your last reply, this gritcode conversation " "continued with another model. What happened since, for " "context:]\n\n"; - text += t + "[End of transcript. The user's new message follows.]\n\n"; + text += transcript + "[End of transcript. The user's new message follows.]\n\n"; } } - const auto& userMsg = history_[userIdx]; + const auto& userMsg = t.history[userIdx]; text += StringField(userMsg, "content"); + if (std::string note = StringField(userMsg, "note"); !note.empty()) + text += "\n\n[gritcode: " + note + "]"; nlohmann::json content = nlohmann::json::array(); if (userMsg.contains("images") && userMsg["images"].is_array()) { for (const auto& img : userMsg["images"]) { @@ -5479,8 +6276,8 @@ void ChatFrame::StartClaudeTurn(bool fresh) { // Extra context and config go through files: that keeps a large // AGENTS.md clear of argv limits and Windows command-line quoting. - claudeTempFiles_.clear(); - auto writeTemp = [this](const std::string& content) -> std::string { + t.claudeTempFiles.clear(); + auto writeTemp = [&t](const std::string& content) -> std::string { wxString tmp = wxFileName::CreateTempFileName("gritclaude"); if (tmp.empty()) return std::string(); std::ofstream f(tmp.ToStdString(wxConvUTF8), std::ios::binary | std::ios::trunc); @@ -5490,8 +6287,8 @@ void ChatFrame::StartClaudeTurn(bool fresh) { wxRemoveFile(tmp); return std::string(); } - claudeTempFiles_.push_back(tmp.ToStdString(wxConvUTF8)); - return claudeTempFiles_.back(); + t.claudeTempFiles.push_back(tmp.ToStdString(wxConvUTF8)); + return t.claudeTempFiles.back(); }; // Project instructions: Claude Code reads CLAUDE.md itself but not @@ -5505,8 +6302,10 @@ void ChatFrame::StartClaudeTurn(bool fresh) { "run_project tool (mcp__gritcode__run_project): 'get' shows it, " "'detect' suggests one, 'set' stores it (test it with Bash first, and " "pass cwd = the project root), 'forget' removes it."; - const std::string agents = LoadAgentsInstructions(activeCwd_); + const std::string agents = LoadAgentsInstructions(t.cwd); if (!agents.empty()) append += "\n\n" + agents; + if (std::string folder = FolderContext(t); !folder.empty()) + append += "\n\n" + folder; const std::string promptFile = writeTemp(append); // gritcode's own stdio MCP server, in its run_project-only mode, so the @@ -5517,7 +6316,7 @@ void ChatFrame::StartClaudeTurn(bool fresh) { {"gritcode", { {"type", "stdio"}, {"command", wxStandardPaths::Get().GetExecutablePath().utf8_string()}, - {"args", {"--mcp-stdio", "--run-project", activeCwd_}}, + {"args", {"--mcp-stdio", "--run-project", t.cwd}}, }}, }}, }; @@ -5526,14 +6325,14 @@ void ChatFrame::StartClaudeTurn(bool fresh) { ClaudeRunSpec spec; spec.executable = exe; - spec.cwd = activeCwd_; + spec.cwd = t.cwd; spec.args = { "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", - "--model", claudeModel_, + "--model", t.claudeModel, // gritcode's own agent runs its tools without confirmation, and a // headless run has nobody to answer a prompt: Claude gets the same. "--permission-mode", "bypassPermissions", @@ -5560,74 +6359,86 @@ void ChatFrame::StartClaudeTurn(bool fresh) { + "\n"; // Reset per-run stream state. - claudeBuf_.clear(); - claudeSessionId_.clear(); - claudeResumed_ = !resumeId.empty(); - claudeResultSeen_ = false; - claudeIsError_ = false; - claudeErrorText_.clear(); - claudeMsgId_.clear(); - claudeStreamMsgId_.clear(); - claudePendingMsg_ = nullptr; - claudeStreamedMsgIds_.clear(); - claudeToolUses_.clear(); - activeAssistantText_.clear(); - activeReasoning_.clear(); - thinkingEmitted_ = false; - liveThinkingIdx_ = -1; - mdStream_ = std::make_unique([this](Block b) { - canvas_->AddBlock(std::move(b)); - }); + t.claudeBuf.clear(); + t.claudeSessionId.clear(); + t.claudeResumed = !resumeId.empty(); + t.claudeResultSeen = false; + t.claudeIsError = false; + t.claudeErrorText.clear(); + t.claudeMsgId.clear(); + t.claudeStreamMsgId.clear(); + t.claudePendingMsg = nullptr; + t.claudeStreamedMsgIds.clear(); + t.claudeToolUses.clear(); + t.activeAssistantText.clear(); + t.activeReasoning.clear(); + t.thinkingEmitted = false; + t.liveThinkingIdx = -1; + t.mdStream = MakeMdStream(t); + + LogDebug("=== CLAUDE model=" + t.claudeModel - LogDebug("=== CLAUDE model=" + claudeModel_ + (resumeId.empty() ? std::string(" (new session)") : " resume=" + resumeId) + " stdin_bytes=" + std::to_string(spec.stdinPayload.size()) + " ==="); - ChdirToCwd(activeCwd_); - claudeProc_ = ClaudeAgentProcess( + ++t.gen; + t.claudeProc = ClaudeAgentProcess( this, std::move(spec), - [this](std::string_view chunk) { OnClaudeData(chunk); }, - [this](ClaudeProcessResult res) { OnClaudeDone(std::move(res)); }); + [this, id = t.id, gen = t.gen](std::string_view chunk) { + if (Thread* t = Find(id, gen)) OnClaudeData(*t, chunk); + }, + [this, id = t.id, gen = t.gen](ClaudeProcessResult res) { + if (Thread* t = Find(id, gen)) OnClaudeDone(*t, std::move(res)); + }); } -void ChatFrame::OnClaudeData(std::string_view chunk) { - claudeBuf_.append(chunk.data(), chunk.size()); +void ChatFrame::OnClaudeData(Thread& t, std::string_view chunk) { + t.claudeBuf.append(chunk.data(), chunk.size()); size_t start = 0; size_t nl; - while ((nl = claudeBuf_.find('\n', start)) != std::string::npos) { - std::string_view line(claudeBuf_.data() + start, nl - start); + while ((nl = t.claudeBuf.find('\n', start)) != std::string::npos) { + std::string_view line(t.claudeBuf.data() + start, nl - start); start = nl + 1; if (!line.empty() && line.back() == '\r') line.remove_suffix(1); if (line.empty() || line.front() != '{') continue; try { - HandleClaudeEvent(nlohmann::json::parse(line)); + HandleClaudeEvent(t, nlohmann::json::parse(line)); } catch (const std::exception& e) { LogDebug(std::string("claude: skipped event: ") + e.what()); } } - claudeBuf_.erase(0, start); + t.claudeBuf.erase(0, start); } -void ChatFrame::HandleClaudeEvent(const nlohmann::json& ev) { +void ChatFrame::HandleClaudeEvent(Thread& t, const nlohmann::json& ev) { if (!ev.is_object()) return; const std::string type = StringField(ev, "type"); if (type == "system") { - if (StringField(ev, "subtype") == "init") { - claudeSessionId_ = StringField(ev, "session_id"); - LogDebug("claude: session " + claudeSessionId_ + " model " + const std::string subtype = StringField(ev, "subtype"); + if (subtype == "init") { + t.claudeSessionId = StringField(ev, "session_id"); + LogDebug("claude: session " + t.claudeSessionId + " model " + StringField(ev, "model")); + } else if (subtype == "task_notification") { + // A subagent run in the background has ended. + if (Thread* c = ChildFor(t, StringField(ev, "tool_use_id"))) { + const std::string status = StringField(ev, "status"); + FinishClaudeSubagent(*c, status == "completed" + ? std::string() + : "The subagent ended: " + status + "."); + } } return; } if (type == "result") { - claudeResultSeen_ = true; + t.claudeResultSeen = true; auto isErr = ev.find("is_error"); - claudeIsError_ = isErr != ev.end() && isErr->is_boolean() && isErr->get(); - if (claudeSessionId_.empty()) claudeSessionId_ = StringField(ev, "session_id"); - if (claudeIsError_) { + t.claudeIsError = isErr != ev.end() && isErr->is_boolean() && isErr->get(); + if (t.claudeSessionId.empty()) t.claudeSessionId = StringField(ev, "session_id"); + if (t.claudeIsError) { std::string msg; auto errs = ev.find("errors"); if (errs != ev.end() && errs->is_array()) { @@ -5639,15 +6450,20 @@ void ChatFrame::HandleClaudeEvent(const nlohmann::json& ev) { } if (msg.empty()) msg = StringField(ev, "result"); if (msg.empty()) msg = StringField(ev, "subtype"); - claudeErrorText_ = msg; + t.claudeErrorText = msg; } return; } - // Subagent traffic (parent_tool_use_id set) stays inside the Agent tool's - // own result; only the main conversation is rendered and recorded. - auto parent = ev.find("parent_tool_use_id"); - if (parent != ev.end() && !parent->is_null()) return; + // Subagent traffic (parent_tool_use_id set) goes to the subagent's child + // thread, which assembles it with the code below. Nested subagents' + // traffic has no thread and is dropped. + auto parentId = ev.find("parent_tool_use_id"); + if (parentId != ev.end() && !parentId->is_null() && t.parent.empty()) { + if (Thread* c = ChildFor(t, StringField(ev, "parent_tool_use_id"))) + HandleClaudeEvent(*c, ev); + return; + } if (type == "stream_event") { auto eIt = ev.find("event"); @@ -5655,36 +6471,41 @@ void ChatFrame::HandleClaudeEvent(const nlohmann::json& ev) { const auto& e = *eIt; const std::string et = StringField(e, "type"); if (et == "message_start") { - // A new API round: settle the previous round's thinking. - EmitPendingThinking(); - activeReasoning_.clear(); - thinkingEmitted_ = false; - liveThinkingIdx_ = -1; + // A new API round: settle the previous round's thinking and + // record its message, so the in-flight text (shown again when + // switching back to this thread) is only this round's. + EmitPendingThinking(t); + FlushClaudePendingMessage(t); + t.activeAssistantText.clear(); + t.activeReasoning.clear(); + + t.thinkingEmitted = false; + t.liveThinkingIdx = -1; auto msg = e.find("message"); - claudeStreamMsgId_ = msg != e.end() ? StringField(*msg, "id") : std::string(); + t.claudeStreamMsgId = msg != e.end() ? StringField(*msg, "id") : std::string(); } else if (et == "content_block_start") { auto block = e.find("content_block"); if (block != e.end() && StringField(*block, "type") == "tool_use") - EmitPendingThinking(); + EmitPendingThinking(t); } else if (et == "content_block_delta") { auto dIt = e.find("delta"); if (dIt == e.end() || !dIt->is_object()) return; const std::string dt = StringField(*dIt, "type"); if (dt == "text_delta") { - const std::string t = StringField(*dIt, "text"); - if (t.empty()) return; - EmitPendingThinking(); - claudeStreamedMsgIds_.insert(claudeStreamMsgId_); - activeAssistantText_ += t; - if (mdStream_) mdStream_->Feed(wxString::FromUTF8(t)); + const std::string text = StringField(*dIt, "text"); + if (text.empty()) return; + EmitPendingThinking(t); + t.claudeStreamedMsgIds.insert(t.claudeStreamMsgId); + t.activeAssistantText += text; + if (t.mdStream) t.mdStream->Feed(wxString::FromUTF8(text)); } else if (dt == "thinking_delta") { - activeReasoning_ += StringField(*dIt, "thinking"); - UpdateLiveThinking(); + t.activeReasoning += StringField(*dIt, "thinking"); + UpdateLiveThinking(t); } } else if (et == "content_block_stop") { // Close the markdown block so a following tool call or text block // starts on its own. - if (mdStream_) mdStream_->Flush(); + if (t.mdStream) t.mdStream->Flush(); } return; } @@ -5703,38 +6524,40 @@ void ChatFrame::HandleClaudeEvent(const nlohmann::json& ev) { const std::string id = StringField(msg, "id"); // Claude Code sends one "assistant" event per finished content block; // blocks of the same API message share its id. - if (id != claudeMsgId_ || !claudePendingMsg_.is_object()) { - FlushClaudePendingMessage(); - claudeMsgId_ = id; - claudePendingMsg_ = {{"role", "assistant"}, + if (id != t.claudeMsgId || !t.claudePendingMsg.is_object()) { + FlushClaudePendingMessage(t); + t.claudeMsgId = id; + t.claudePendingMsg = {{"role", "assistant"}, {"content", ""}, - {"model", claudeModel_}}; - if (!claudeSessionId_.empty()) - claudePendingMsg_["claudeSessionId"] = claudeSessionId_; + {"model", t.claudeModel}}; + if (!t.claudeSessionId.empty()) + t.claudePendingMsg["claudeSessionId"] = t.claudeSessionId; } auto blocks = msg.find("content"); if (blocks == msg.end() || !blocks->is_array()) return; for (const auto& b : *blocks) { const std::string bt = StringField(b, "type"); if (bt == "text") { - const std::string t = StringField(b, "text"); - if (t.empty()) continue; - std::string c = StringField(claudePendingMsg_, "content"); + const std::string text = StringField(b, "text"); + if (text.empty()) continue; + std::string c = StringField(t.claudePendingMsg, "content"); if (!c.empty()) c += "\n\n"; - claudePendingMsg_["content"] = c + t; + t.claudePendingMsg["content"] = c + text; // Not streamed as deltas (an older CLI, say): render it whole. - if (!claudeStreamedMsgIds_.count(id) && mdStream_) { - EmitPendingThinking(); - mdStream_->Feed(wxString::FromUTF8(t)); - mdStream_->Flush(); + if (!t.claudeStreamedMsgIds.count(id) && t.mdStream) { + EmitPendingThinking(t); + t.mdStream->Feed(wxString::FromUTF8(text)); + t.mdStream->Flush(); } } else if (bt == "thinking") { - const std::string t = StringField(b, "thinking"); - if (t.empty()) continue; - std::string r = StringField(claudePendingMsg_, "reasoning_content"); + const std::string thinking = StringField(b, "thinking"); + if (thinking.empty()) continue; + std::string r = StringField(t.claudePendingMsg, "reasoning_content"); if (!r.empty()) r += "\n\n"; - claudePendingMsg_["reasoning_content"] = r + t; - if (activeReasoning_.empty() && !thinkingEmitted_) activeReasoning_ = t; + t.claudePendingMsg["reasoning_content"] = r + thinking; + if (t.activeReasoning.empty() && !t.thinkingEmitted) + t.activeReasoning = thinking; + } else if (bt == "tool_use") { const std::string toolId = StringField(b, "id"); const std::string name = StringField(b, "name"); @@ -5742,14 +6565,20 @@ void ChatFrame::HandleClaudeEvent(const nlohmann::json& ev) { std::string args = input != b.end() ? input->dump(-1, ' ', false, nlohmann::json::error_handler_t::replace) : std::string("{}"); - claudeToolUses_[toolId] = {name, args}; - if (!claudePendingMsg_.contains("tool_calls")) - claudePendingMsg_["tool_calls"] = nlohmann::json::array(); - claudePendingMsg_["tool_calls"].push_back({ + t.claudeToolUses[toolId] = {name, args}; + if (!t.claudePendingMsg.contains("tool_calls")) + t.claudePendingMsg["tool_calls"] = nlohmann::json::array(); + nlohmann::json call = { {"id", toolId}, {"type", "function"}, {"function", {{"name", name}, {"arguments", args}}}, - }); + }; + // Claude Code's subagent tool (Task in older CLIs). + if (t.parent.empty() && (name == "Agent" || name == "Task")) { + call["thread"] = StartClaudeSubagent( + t, toolId, input != b.end() ? *input : nlohmann::json()).id; + } + t.claudePendingMsg["tool_calls"].push_back(std::move(call)); } } return; @@ -5762,10 +6591,10 @@ void ChatFrame::HandleClaudeEvent(const nlohmann::json& ev) { if (blocks == msgIt->end() || !blocks->is_array()) return; for (const auto& b : *blocks) { if (StringField(b, "type") != "tool_result") continue; - // The calls this result answers go into history_ first. - FlushClaudePendingMessage(); - if (mdStream_) mdStream_->Flush(); - EmitPendingThinking(); + // The calls this result answers go into history first. + FlushClaudePendingMessage(t); + if (t.mdStream) t.mdStream->Flush(); + EmitPendingThinking(t); const std::string toolId = StringField(b, "tool_use_id"); auto rc = b.find("content"); @@ -5774,71 +6603,83 @@ void ChatFrame::HandleClaudeEvent(const nlohmann::json& ev) { result.resize(kClaudeToolResultMaxChars); result += "\n[output truncated]"; } - auto use = claudeToolUses_.find(toolId); - const std::string name = use != claudeToolUses_.end() ? use->second.first + auto use = t.claudeToolUses.find(toolId); + const std::string name = use != t.claudeToolUses.end() ? use->second.first : std::string("tool"); - const std::string args = use != claudeToolUses_.end() ? use->second.second + const std::string args = use != t.claudeToolUses.end() ? use->second.second : std::string(); - RenderToolBlock(name, args, result); - history_.push_back({ + Thread* child = ChildFor(t, toolId); + RenderToolBlock(t, name, args, result, child ? child->id : std::string()); + // The subagent's result ends its thread, unless it was started in + // the background: then this only says so, and its + // task_notification follows when it ends. + auto info = ev.find("tool_use_result"); + const bool async = info != ev.end() && info->is_object() + && info->value("isAsync", false); + if (child && !async) { + const bool isError = b.value("is_error", false); + FinishClaudeSubagent(*child, isError ? "The subagent failed: " + result + : std::string()); + } + t.history.push_back({ {"role", "tool"}, {"tool_call_id", toolId}, {"name", name}, {"content", result}, - {"model", claudeModel_}, + {"model", t.claudeModel}, }); } } } -void ChatFrame::FlushClaudePendingMessage() { - if (!claudePendingMsg_.is_object()) return; - nlohmann::json msg = std::move(claudePendingMsg_); - claudePendingMsg_ = nullptr; +void ChatFrame::FlushClaudePendingMessage(Thread& t) { + if (!t.claudePendingMsg.is_object()) return; + nlohmann::json msg = std::move(t.claudePendingMsg); + t.claudePendingMsg = nullptr; const bool hasTools = msg.contains("tool_calls") && !msg["tool_calls"].empty(); const bool hasText = !StringField(msg, "content").empty(); if (!hasText && !hasTools && StringField(msg, "reasoning_content").empty()) return; // OpenAI shape: content is null when a message only calls tools. if (!hasText && hasTools) msg["content"] = nullptr; - history_.push_back(std::move(msg)); + t.history.push_back(std::move(msg)); } -void ChatFrame::OnClaudeDone(ClaudeProcessResult res) { - if (!claudeBuf_.empty()) OnClaudeData("\n"); // a final unterminated line - for (const auto& f : claudeTempFiles_) wxRemoveFile(wxString::FromUTF8(f)); - claudeTempFiles_.clear(); +void ChatFrame::OnClaudeDone(Thread& t, ClaudeProcessResult res) { + if (!t.claudeBuf.empty()) OnClaudeData(t, "\n"); // a final unterminated line + for (const auto& f : t.claudeTempFiles) wxRemoveFile(wxString::FromUTF8(f)); + t.claudeTempFiles.clear(); if (quitRequested_) { Close(); return; } - if (mdStream_) mdStream_->Flush(); - mdStream_.reset(); - EmitPendingThinking(); - FlushClaudePendingMessage(); + if (t.mdStream) t.mdStream->Flush(); + t.mdStream.reset(); + EmitPendingThinking(t); + FlushClaudePendingMessage(t); LogDebug("=== CLAUDE done exit=" + std::to_string(res.exitCode) + (res.cancelled ? " cancelled" : "") - + (claudeResultSeen_ ? (claudeIsError_ ? " result=error" : " result=ok") + + (t.claudeResultSeen ? (t.claudeIsError ? " result=error" : " result=ok") : " no-result") + " ==="); if (!res.stderrTail.empty()) LogDebug("claude stderr: " + res.stderrTail); if (!res.started) { - FailClaudeTurn("couldn't start claude (" + res.error + ")"); + FailClaudeTurn(t, "couldn't start claude (" + res.error + ")"); return; } if (res.cancelled) { // SIGINT let Claude Code record the partial turn; what we already // recorded stays too, so the next turn resumes consistently. - RenderErrorBlock("Cancelled."); - FinalizeTurn(true); + RenderErrorBlock(t, "Cancelled."); + FinalizeTurn(t, true); return; } - std::string detail = claudeErrorText_; - if (detail.empty() && !claudeResultSeen_) { + std::string detail = t.claudeErrorText; + if (detail.empty() && !t.claudeResultSeen) { detail = res.stderrTail; while (!detail.empty() && std::isspace((unsigned char)detail.back())) detail.pop_back(); @@ -5847,24 +6688,24 @@ void ChatFrame::OnClaudeDone(ClaudeProcessResult res) { // Claude Code prunes old session transcripts; a gritcode session can // outlive the Claude session it points at. Start a new one and hand over // the whole conversation instead. - if (claudeResumed_ && !claudeRetriedFresh_ + if (t.claudeResumed && !t.claudeRetriedFresh && detail.find("No conversation found") != std::string::npos) { - claudeRetriedFresh_ = true; + t.claudeRetriedFresh = true; LogDebug("claude: session gone, retrying without --resume"); - StartClaudeTurn(/*fresh=*/true); + StartClaudeTurn(t, /*fresh=*/true); return; } - if (claudeResultSeen_ && !claudeIsError_) { - FinalizeTurn(); + if (t.claudeResultSeen && !t.claudeIsError) { + FinalizeTurn(t); return; } if (detail.empty()) detail = "Claude Code exited with status " + std::to_string(res.exitCode) + "."; - FailClaudeTurn(detail); + FailClaudeTurn(t, detail); } -void ChatFrame::FailClaudeTurn(const std::string& detail) { +void ChatFrame::FailClaudeTurn(Thread& t, const std::string& detail) { std::string shown = detail; if (shown.size() > 1500) shown = shown.substr(0, 1500) + "…"; std::string lower = detail; @@ -5882,12 +6723,11 @@ void ChatFrame::FailClaudeTurn(const std::string& detail) { } else { msg = "Claude Code error: " + wxString::FromUTF8(shown); } - RenderErrorBlock(msg); - // Nothing from this turn was recorded: drop the unanswered user message, // as the HTTP error path does, so it isn't sent again with the next one. - if (history_.size() == claudeTurnHistoryStart_ && !history_.empty() - && IsUserTurn(history_.back())) - history_.pop_back(); - FinalizeTurn(true); + const bool rolledBack = t.history.size() == t.claudeTurnHistoryStart + && !t.history.empty() && IsUserTurn(t.history.back()); + if (rolledBack) t.history.pop_back(); + ShowTurnFailure(t, NoticeBlock(msg), rolledBack); + FinalizeTurn(t, true); } diff --git a/src/chat_frame.h b/src/chat_frame.h index c7f2f24..f6d3128 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -18,12 +18,17 @@ #include "md_parser.h" #include "mcp_server.h" #include "memory.h" +#include "project_brief.h" #include "session_store.h" #include "streaming_web_request.h" +#include "thread_list.h" #include "tools.h" #include +#include +#include #include #include +#include #include #include #include @@ -52,7 +57,8 @@ class ChatFrame : public wxFrame { wxButton* clearQueueBtn_ = nullptr; // Chip row sitting between the canvas and the input. Hidden when the - // queue is empty; rebuilt from pendingQueue_ on every queue change. + // queue is empty; rebuilt from the active thread's pendingQueue on every + // queue change. wxPanel* chipRow_ = nullptr; wxSizer* chipSizer_ = nullptr; @@ -74,10 +80,6 @@ class ChatFrame : public wxFrame { std::vector images; }; - // Pending user messages typed/queued while a turn was in flight. On - // natural turn end the front auto-dispatches; on error/cancel we land - // in idle-queue mode. - std::vector pendingQueue_; static constexpr size_t kMaxQueue_ = 5; // Toolbar row above the input. Plain wxBoxSizer with two labelled @@ -97,17 +99,215 @@ class ChatFrame : public wxFrame { wxBitmapButton* hamburgerBtn_ = nullptr; wxBitmapButton* editorBtn_ = nullptr; - // On-disk session persistence. Sessions are keyed by working directory - // (one session per folder, gritcode model). The dropdown is rebuilt from - // store_.List(); index 0 is the "New Session…" sentinel and indices 1..N - // map to cwds in sessionCwds_ (parallel to the dropdown rows). + // Streaming tool_call accumulator. Each delta carries an `index` field that + // identifies which call the (id/name/arguments) fragments belong to. + struct StreamToolCall { + std::string id; + std::string name; + std::string args; // JSON string, accumulated across deltas + }; + + // Worker-thread tool dispatch result (one entry per tool call). Populated + // off the GUI thread (or by a subagent); consumed in OnToolBatchDone. + struct ToolBatchEntry { + std::string id; + std::string name; + std::string argsJson; // raw arguments string for display + std::string result; + }; + + // One conversation. Several threads can run at once (one running turn per + // folder); only the active one draws to the canvas, the others update + // their data only. + struct Thread { + std::string id; // session file id (SessionStore::Entry::id) + std::string cwd; // tools and Claude Code run here + std::string title; // from the first user message + std::string parent; // parent thread id, empty for top-level threads + std::string lastUsed; // ISO 8601, orders the dropdown + // A thread with a parent is a subagent: a `task` call run headless, or + // a Claude Code subagent shown from its parent's stream. It is + // view-only. parentToolId is the parent's tool call it answers (known + // while this run has it open); failure is why its turn failed, which + // the parent gets as that call's result. + std::string parentToolId; + std::string failure; + // Bumped whenever the thread's request, Claude process or tool worker + // is replaced. Async callbacks capture (id, gen) and are dropped when + // the gen no longer matches (see Find). + uint32_t gen = 0; + + // Chat history as raw OpenAI-format messages. System prompt is the + // first entry; user/assistant/tool messages are appended in order. + std::vector history; + std::string lastAgentsContent; // AGENTS.md text currently in history[0] + + // True from StartTurn until FinalizeTurn. + bool streaming = false; + // A turn of this top-level thread ended while it wasn't shown, and it + // hasn't been viewed since (marked in the dropdown and thread list). + // A turn the user stopped leaves nothing new to see. + bool unseen = false; + bool stopRequested = false; // RequestCancel during this turn + // The input as the user left it on switching away: text and images. + wxString draft; + std::vector draftImages; + // Display-only blocks (Play runs): in the canvas, never sent to the + // model or saved. `after` is the history size when one was added, so + // a rebuilt canvas puts it back in place. + struct LocalBlock { + size_t after; + Block block; + }; + std::vector localBlocks; + // Pending user messages typed/queued while a turn was in flight. On + // natural turn end the front auto-dispatches; on error/cancel we land + // in idle-queue mode. + std::vector pendingQueue; + // The current turn's user text as typed (without hidden instructions). + std::string turnPrompt; + // A turn that failed while the thread wasn't shown: its notice, and its + // prompt when the failure rolled that out of history. Switching to the + // thread shows the notice and puts the prompt back into the input, so + // a background failure never vanishes silently. + std::optional failureNotice; + std::string failedPrompt; + + // Model index the current turn started with. A switch to Claude in the + // middle of an OpenAI-style tool loop can't take over that loop, so the + // loop keeps this model until the turn ends. + int turnModelIndex = 0; + // Model id of the in-flight OpenAI-style completion, recorded on the + // assistant messages it produces. + std::string requestModel; + + StreamingWebRequest request; + // Feeds streamed assistant text to the canvas while the thread is shown. + std::unique_ptr mdStream; + std::string activeAssistantText; // in-flight text, not yet in history + std::string activeReasoning; // accumulated reasoning_content for the in-flight assistant turn + // Per-completion-round flag. Reset in StartCompletion. Flipped true the + // first time we emit a Thinking block for this round (either at the + // first content/tool_call delta, or at HandleCompletion for a pure- + // reasoning response). Keeps us from rendering the same reasoning twice + // when a delta carries both reasoning and content. + bool thinkingEmitted = false; + // Canvas index of this round's Thinking block while reasoning is still + // streaming (-1 when there is none, or the thread isn't shown). It is + // added collapsed at the first reasoning delta; while expanded, its + // text is refreshed a few times a second so the user can watch the + // reasoning. EmitPendingThinking / FinalizeTurn finalize it. + int liveThinkingIdx = -1; + std::chrono::steady_clock::time_point liveThinkingLastUpdate{}; + // How long the last live re-layout took; paces the next one. + std::chrono::steady_clock::duration liveThinkingLayoutCost{}; + // finish_reason of the in-flight completion ("stop", "length", + // "tool_calls", ...) and the max_tokens it was sent with, so a reply that + // hit the output limit can say so instead of ending silently. + std::string finishReason; + int requestMaxTokens = 0; + + // SSE parsing state. Doubles as raw-body capture: on a non-2xx response + // the server sends a JSON error body instead of SSE, which lands here + // verbatim and we display it in the error block. + std::string sseBuf; + std::vector activeToolCalls; + + // Number of tool dispatch rounds taken in the current user turn. + // Telemetry only — the loop ends when the model stops emitting tool calls. + int toolIter = 0; + + // Shared with the tool worker thread, if any. Set when a batch starts + // in HandleCompletion, cleared in OnToolBatchDone. Escape flips + // `cancelled` and signals any in-flight bash subprocess via `activePgid`. + std::shared_ptr currentToolToken; + // Tool dispatch worker. Owned (not detached) so the destructor can + // cancel and join it — otherwise a long-running bash invocation could + // outlive the frame. + std::thread toolWorker; + bool toolWorkerRunning = false; + // The current tool batch in call order. The worker fills in the + // ordinary calls and each `task` call's child thread its own; the + // batch ends (OnToolBatchDone) once the worker and the children are done. + std::vector toolBatch; + + // ---- Context compaction ---- + // historyCompactBaseCount: message count immediately after the last + // successful compaction. The hysteresis check (<5 growth since then) + // prevents firing a new compaction on every single turn — history + // only grows between requests, so without the gate we'd re-compact + // the same head every time. + // compacting: true while a summary LLM call is in flight. Set by + // RunSummaryThenSend, cleared by ApplyCompaction. StartCompletion + // skips the budget check while this is true. + int historyCompactBaseCount = 0; + bool compacting = false; + // Buffered state for the in-flight summary request. + std::string summarySseBuf; + std::string summaryText; + int compactionSplitIdx = -1; + int compactionHeadCount = 0; + // Set once per turn after an overflow-forced compaction, so a second + // provider overflow surfaces an error instead of looping. + bool overflowRetried = false; + + // ---- Claude turns (the user's own Claude Code CLI) ---- + // A Claude turn runs `claude -p` with stream-json in and out. Claude + // Code runs its own agent loop and tools; gritcode renders the events + // and records them in history in the same OpenAI-style shape as other + // models, tagged with "model" and "claudeSessionId" so the next turn + // can --resume the same Claude Code session. + ClaudeAgentProcess claudeProc; + std::string claudeBuf; // partial stdout line + std::string claudeModel; // model id the turn was sent to + std::string claudeSessionId; // from system/init of this run + // Temp files for this run (--append-system-prompt-file, --mcp-config), + // removed when it ends. + std::vector claudeTempFiles; + bool claudeResumed = false; // this run passed --resume + bool claudeRetriedFresh = false; // already retried without --resume + bool claudeResultSeen = false; + bool claudeIsError = false; + std::string claudeErrorText; + size_t claudeTurnHistoryStart = 0; // history size after the user message + // Top-level assistant message being assembled from per-block + // "assistant" events (same API message id). Flushed into history + // before its tool results, or when the next message starts. + std::string claudeMsgId; + nlohmann::json claudePendingMsg; + std::string claudeStreamMsgId; // message being streamed now + std::set claudeStreamedMsgIds; // messages whose text streamed + // tool_use id -> (name, compact JSON input), for rendering the result. + std::map> claudeToolUses; + }; + + // Every thread opened in this run (std::deque: addresses stay stable as + // threads are added). threads_[active_] is the one on the canvas. + std::deque threads_; + size_t active_ = 0; + Thread& Active() { return threads_[active_]; } + const Thread& Active() const { return threads_[active_]; } + bool Shown(const Thread& t) const { return &t == &threads_[active_]; } + // Null when no open thread has this id (or, with `gen`, when the thread + // has moved on since an async callback captured it). + Thread* Find(const std::string& id); + Thread* Find(const std::string& id, uint32_t gen); + // Dropdown label: title, or a placeholder for untitled threads. + static wxString ThreadLabel(const std::string& id, const std::string& cwd, + const std::string& title); + // The label of thread `id`, open or not. + wxString ThreadLabelOf(const std::string& id); + + // On-disk persistence: one session file per thread. The dropdown lists + // "New Session…" and then the folders (rows map to sessionCwds_); a + // folder's threads are switched in the left pane. SessionStore store_; std::vector sessionCwds_; + // Folder shown in the editor and file tree: the active thread's cwd. std::string activeCwd_; - std::string lastAgentsContent_; // AGENTS.md text currently in history_[0] - // Cross-project memory: FTS5 index over every saved session. Opened at - // construction; written after every PersistActive(); queried by the + // Cross-project memory: FTS5 index over every saved thread. Opened at + // construction; written after every Persist(); queried by the // grit_history_search tool. If the open fails (e.g. read-only home) the // tool returns "Memory index unavailable" — memory is an enhancement, // not required for normal chat. @@ -122,77 +322,18 @@ class ChatFrame : public wxFrame { // together. int currentModelIndex_ = 0; std::vector remoteModels_; - // Model index the current turn started with. A switch to Claude in the - // middle of an OpenAI-style tool loop can't take over that loop, so the - // loop keeps this model until the turn ends. - int turnModelIndex_ = 0; - // Model id of the in-flight OpenAI-style completion, recorded on the - // assistant messages it produces. - std::string requestModel_; - - StreamingWebRequest request_; - std::unique_ptr mdStream_; - - // Chat history as raw OpenAI-format messages. System prompt is the first - // entry; user/assistant/tool messages are appended in order. - std::vector history_; - std::string activeAssistantText_; - std::string activeReasoning_; // accumulated reasoning_content for the in-flight assistant turn - // Per-completion-round flag. Reset in StartCompletion. Flipped true the - // first time we emit a Thinking block for this round (either at the - // first content/tool_call delta, or at HandleCompletion for a pure- - // reasoning response). Keeps us from rendering the same reasoning twice - // when a delta carries both reasoning and content. - bool thinkingEmitted_ = false; - // Canvas index of this round's Thinking block while reasoning is still - // streaming (-1 when there is none). It is added collapsed at the first - // reasoning delta; while expanded, its text is refreshed a few times a - // second so the user can watch the reasoning. EmitPendingThinking / - // FinalizeTurn finalize it. - int liveThinkingIdx_ = -1; - std::chrono::steady_clock::time_point liveThinkingLastUpdate_{}; - // How long the last live re-layout took; paces the next one. - std::chrono::steady_clock::duration liveThinkingLayoutCost_{}; - // finish_reason of the in-flight completion ("stop", "length", - // "tool_calls", ...) and the max_tokens it was sent with, so a reply that - // hit the output limit can say so instead of ending silently. - std::string finishReason_; - int requestMaxTokens_ = 0; - bool streaming_ = false; - - // SSE parsing state. Doubles as raw-body capture: on a non-2xx response the - // server sends a JSON error body instead of SSE, which lands here verbatim - // and we display it in the error block. - std::string sseBuf_; - // Streaming tool_call accumulator. Each delta carries an `index` field that - // identifies which call the (id/name/arguments) fragments belong to. - struct StreamToolCall { - std::string id; - std::string name; - std::string args; // JSON string, accumulated across deltas + // Play-button runs, one per folder, each on its own worker, so a + // long-running dev server blocks neither tool dispatch nor another + // folder's run. Play again in a folder restarts that folder's run. + struct PlayRun { + std::string cwd; + std::shared_ptr token; // null once it ended + std::thread worker; }; - std::vector activeToolCalls_; - - // Number of tool dispatch rounds taken in the current user turn. - // Telemetry only — the loop ends when the model stops emitting tool calls. - int toolIter_ = 0; - - // Shared with the active tool worker thread, if any. Set when a batch - // starts in HandleCompletion, cleared in OnToolBatchDone. Escape flips - // `cancelled` and signals any in-flight bash subprocess via `activePgid`. - std::shared_ptr currentToolToken_; - - // Tool dispatch worker. Owned (not detached) so the destructor can cancel - // and join it — otherwise a long-running bash invocation could outlive the - // frame and call wxQueueEvent on a dangling `this`. - std::thread toolWorker_; - - // Separate thread for Play-button runs so a long-running dev server - // doesn't block the tool dispatch worker (they share the same cancellation - // pattern but must not contend for the same std::thread slot). - std::shared_ptr currentPlayToken_; - std::thread playWorker_; + std::deque plays_; + // Cancels `run` if it still runs and waits for its worker. + static void StopPlay(PlayRun& run); // Persist worker: runs the heavy SessionStore::WriteSessionFile + // MemoryDB::RebuildSession off the UI thread. Owned (not detached) so the @@ -206,12 +347,12 @@ class ChatFrame : public wxFrame { WebCancelToken remoteModelsCancel_; // Graceful close — see OnClose. - void RestoreSession(); // renders history_ into the canvas + clears loading + void RestoreSession(); // renders the active thread into the canvas + clears loading void RebuildImageRow(); void RemovePendingImageByHash(const std::string& hash); bool TryPasteImage(); - bool HistoryIsLarge() const; // >200 msgs or >250KB content + static bool HistoryIsLarge(const Thread& t); // >200 msgs or >250KB content void RestoreCanvasMaybeDeferred(); // loading placeholder + deferred restore when large bool quitRequested_ = false; @@ -239,20 +380,20 @@ class ChatFrame : public wxFrame { void OnCharHook(wxKeyEvent&); void OnClose(wxCloseEvent&); - // Pop the front of pendingQueue_ and dispatch it as a fresh turn. + // Pop the front of t.pendingQueue and dispatch it as a fresh turn. // No-op on empty. Used both from FinalizeTurn (auto-dispatch on // natural completion) and from the Continue button (idle-queue mode). - void DispatchNextQueued(); - // Reflect (streaming, queue) state across input/Send/Continue/Clear/ - // chip-row visibility and the Send button label. + void DispatchNextQueued(Thread& t); + // Reflect the active thread's (streaming, queue) state across input/Send/ + // Continue/Clear/chip-row visibility and the Send button label. void UpdateQueueUI(); - // Recreate chip widgets to match pendingQueue_. Called from + // Recreate chip widgets to match the active thread's queue. Called from // UpdateQueueUI whenever the queue is non-empty. void RebuildChips(); - // Cancel whatever is currently in flight: HTTP stream, tool batch, or + // Cancel whatever the thread has in flight: HTTP stream, tool batch, or // both. Safe to call when nothing is running. Wired to Escape. - void RequestCancel(); + void RequestCancel(Thread& t); void OnSessionChoice(wxCommandEvent&); void OnModelChoice(wxCommandEvent&); void OnModelContextMenu(wxContextMenuEvent&); @@ -336,29 +477,103 @@ class ChatFrame : public wxFrame { void OnEditorShowInFiles(wxCommandEvent& e); wxTreeItemId FindTreeItemByPath(wxTreeItemId parent, const wxString& path); - // Repopulate the session choice from store_.List() with the leading - // "New Session…" entry, then restore the active selection. + // Repopulate the session dropdown: "New Session…", then the folders, + // most recent first (ones with a running thread marked), selecting the + // active one. Refreshes the left pane's thread list too. void RefreshSessionChoice(); - // Save current state, then create or switch sessions. CreateNewSession - // pops a directory dialog, then either loads existing history for that - // cwd or seeds a fresh one. Switch loads from disk and rebuilds canvas. + // ---- Left pane threads view ---- + // The left pane (hamburger) shows the session reference or the active + // folder's threads, picked by the icons of its bottom toolbar and + // remembered in preferences. + wxBitmapButton* sessionTab_ = nullptr; + wxBitmapButton* threadsTab_ = nullptr; + wxBitmapButton* newThreadBtn_ = nullptr; // Threads view only + wxPanel* loadView_ = nullptr; // session reference + wxPanel* threadsView_ = nullptr; // thread list + ThreadList* threadList_ = nullptr; + // Refreshes the rows' current step while a thread of the folder runs. + wxTimer threadPaneTimer_; + // The active folder's threads, most recent first, each followed by its + // subagents. + std::vector FolderRows(); + // What running thread `t` is doing now: the tool it runs, "writing", + // "thinking", or what it waits for. + wxString CurrentStep(Thread& t); + void UpdateThreadPane(std::vector rows); + // Picks the left pane's view; remembered for next time. + void ShowLeftView(bool threads); + // The toolbar icons, the current view's in the text colour. + void PaintLeftTabs(); + // Whether the thread list is on screen (pane open, Threads view). + bool ThreadsShown() const; + void OnThreadListContextMenu(wxContextMenuEvent& e); + + // CreateNewSession pops a directory dialog and switches to that folder. + // SwitchToCwd opens the most recently used thread of `cwd` (loading it, + // or seeding a fresh one). NewThread adds an empty thread in `cwd`. + // SelectThread shows thread `id`, loading it from disk when needed; false + // when it doesn't exist or the user cancelled (unsaved editor changes). void CreateNewSession(); bool SwitchToCwd(const std::string& cwd); - - // Persist current history to disk under activeCwd_. - void PersistActive(); - - // Wipe blocks_ and re-render every renderable message in history_ — + bool NewThread(const std::string& cwd); + bool SelectThread(const std::string& id); + // Adds an open thread for (id, cwd): loaded from disk when it has a + // session file, otherwise seeded with a fresh system prompt. + Thread& OpenThread(const std::string& id, const std::string& cwd); + // Make open thread `id` the active one and rebuild the canvas from it. + // False when the user cancelled (unsaved editor changes). + bool ShowThread(const std::string& id); + + // Persist the thread's history to its session file. + void Persist(Thread& t); + + // Re-render every renderable message in the active thread's history — // user prompts as UserPrompt blocks, assistant content via MdStream, tool - // call/result pairs as ToolCall blocks. System messages are skipped. + // call/result pairs as ToolCall blocks; system messages are skipped — + // followed by the in-flight turn (partial text). void RestoreCanvasFromHistory(); - // Push the default system prompt into history_. Called on fresh sessions. - void SeedSystemPrompt(); - // Re-inject AGENTS.md instructions into history_[0] for an existing - // (restored or switched-to) session whose system prompt predates them. - void RefreshSystemPromptAgents(); + // System prompt for `t`: the static base, AGENTS.md (recorded in + // t.lastAgentsContent) and FolderContext. + std::string SystemPrompt(Thread& t); + // Push the system prompt into t.history. Called on fresh threads. + void SeedSystemPrompt(Thread& t); + // Rebuild t.history[0] for an existing (restored or switched-to) thread, + // or after AGENTS.md or the brief changed. + void RefreshSystemPrompt(Thread& t); + + // A titled top-level thread of a folder, open or only in the index. + struct FolderThread { + std::string id; + std::string title; + std::string lastUsed; + }; + // The folder's titled top-level threads, most recent first. + std::vector FolderThreads(const std::string& cwd) const; + // The folder's project brief, a short list of its other threads, and in a + // git repo how to work alongside threads sharing its checkout, so a new + // thread knows what the project is and that parallel work exists. + std::string FolderContext(const Thread& t) const; + + // A folder's project brief is written once, when the user starts a new + // thread in a folder without one, by the provider of the selected model + // (see BriefModelIndex), and its later new threads reuse it. + // One job per folder; a failed one runs again at the next new thread. + struct BriefJob { + std::string cwd; + wxString writer; // the model's label + bool running = false; + BriefRun run; + }; + std::deque briefJobs_; + // Starts writing `cwd`'s brief; false when a job for it already runs. + bool WriteProjectBrief(const std::string& cwd); + void OnBriefWritten(const std::string& cwd, std::string brief, std::string error); + // For a thread with no turn yet: the brief it starts with, or that one + // is being written. + void AddBriefNotice(const Thread& t); + static bool Started(const Thread& t); // Re-render SVG icons with the current system foreground color. Called // once at construction and again on EVT_SYS_COLOUR_CHANGED so the icons @@ -410,108 +625,83 @@ class ChatFrame : public wxFrame { int imgFile_ = -1; // Streaming HTTP callbacks (delivered on the GUI thread via CallAfter). - // OnStreamData appends to sseBuf_ and parses any complete SSE events. + // OnStreamData appends to t.sseBuf and parses any complete SSE events + // (with `streamEnded`, also an unterminated last one). // OnStreamDone fires once with the final WebResponse and runs the same // post-stream finalization as the old OnWebRequestState handler did. - void OnStreamData(std::string_view chunk); - void OnStreamDone(WebResponse resp); - - // Worker-thread tool dispatch result (one entry per tool call). Populated - // off the GUI thread; consumed in OnToolBatchDone. - struct ToolBatchEntry { - std::string id; - std::string name; - std::string argsJson; // raw arguments string for display - std::string result; - }; - void OnToolBatchDone(wxThreadEvent& e); + void OnStreamData(Thread& t, std::string_view chunk, bool streamEnded = false); + void OnStreamDone(Thread& t, WebResponse resp); + + // Ends t's tool batch once its worker and subagents are done: records + // the results and continues the turn. + void OnToolBatchDone(Thread& t); + + // ---- Subagents (child threads, one level deep) ---- + // Open child of `t` answering tool call `toolId`, if any. + Thread* ChildFor(const Thread& t, const std::string& toolId); + bool HasRunningChild(const Thread& t) const; + // Adds a child thread of `parent` for tool call `toolId`, titled by + // `description`, and links to it from the parent's canvas. + Thread& OpenChild(Thread& parent, const std::string& toolId, + const std::string& description); + // Runs a `task` call: a child thread with a fresh context does `prompt`. + void StartSubagent(Thread& parent, const std::string& toolId, + const std::string& description, const std::string& prompt); + // A finished `task` child: its report (or failure) becomes the result of + // its parent's call. + void ReportToParent(Thread& child, bool failed); + // A Claude Code subagent (Agent tool call) gets a child thread that shows + // its events; FinishClaudeSubagent ends it, with `failure` when it failed. + Thread& StartClaudeSubagent(Thread& parent, const std::string& toolId, + const nlohmann::json& input); + void FinishClaudeSubagent(Thread& child, const std::string& failure); + // Model index thread t's requests use: the dropdown's, except that a + // subagent keeps the model it was started with. + int ModelIndexFor(const Thread& t) const; // After a non-2xx response: pull a human-readable error string out of - // sseBuf_ (which doubles as raw-body capture under Storage_None). - wxString ExtractErrorBody() const; + // t.sseBuf (which doubles as raw-body capture under Storage_None). + static wxString ExtractErrorBody(const Thread& t); // Starts a model turn for a user message (the only entry point, so every // turn is routed the same way). `hiddenInstructions` is appended to the - // message the model sees but not shown on the canvas. - void StartTurn(const wxString& userText, std::vector images = {}, + // message the model sees but not shown on the canvas. While another + // thread runs a turn in the same folder, the turn waits for it. + void StartTurn(Thread& t, const wxString& userText, + std::vector images = {}, const std::string& hiddenInstructions = std::string()); - void EnqueueMessage(const wxString& text); + void EnqueueMessage(Thread& t, const wxString& text); void LogDebug(const std::string& line); void OpenDebugWindow(); - nlohmann::json BuildModelView() const; + nlohmann::json BuildModelView(const Thread& t) const; void PruneToolOutputs(nlohmann::json& tail) const; - void EmitImageBlock(const std::string& hash, const std::string& mime, const std::string& name); + void EmitImageBlock(Thread& t, const std::string& hash, const std::string& mime, + const std::string& name); Block MakeImageBlock(const std::string& hash, const std::string& mime, const std::string& name); - // Builds and sends the next chat completion request from the current - // history_ vector. Used both to start a turn and to continue after tool - // results have been appended. - void StartCompletion(); + // Markdown stream for the thread's in-flight reply: its blocks go to the + // canvas while the thread is shown and are dropped otherwise. + std::unique_ptr MakeMdStream(Thread& t); + // Builds and sends the next chat completion request from t.history. + // Used both to start a turn and to continue after tool results have + // been appended. + void StartCompletion(Thread& t); // Build + dispatch the actual chat completion request. Split out of // StartCompletion so the compaction path can chain into it directly // after the summary returns without re-checking the budget. - void DoSendActualRequest(); - - // ---- Context compaction ---- - // historyCompactBaseCount_: message count immediately after the last - // successful compaction. The hysteresis check (<5 growth since then) - // prevents firing a new compaction on every single turn — history - // only grows between requests, so without the gate we'd re-compact - // the same head every time. - // compacting_: true while a summary LLM call is in flight. Set by - // RunSummaryThenSend, cleared by ApplyCompaction. StartCompletion - // skips the budget check while this is true. - int historyCompactBaseCount_ = 0; - bool compacting_ = false; - // Buffered state for the in-flight summary request. - std::string summarySseBuf_; - std::string summaryText_; - int compactionSplitIdx_ = -1; - int compactionHeadCount_ = 0; - // Set once per turn after an overflow-forced compaction, so a second - // provider overflow surfaces an error instead of looping. - bool overflowRetried_ = false; - - // ---- Claude turns (the user's own Claude Code CLI) ---- - // A Claude turn runs `claude -p` with stream-json in and out. Claude Code - // runs its own agent loop and tools; gritcode renders the events and - // records them in history_ in the same OpenAI-style shape as other - // models, tagged with "model" and "claudeSessionId" so the next turn can - // --resume the same Claude Code session. - ClaudeAgentProcess claudeProc_; - std::string claudeBuf_; // partial stdout line - std::string claudeModel_; // model id the turn was sent to - std::string claudeSessionId_; // from system/init of this run - // Temp files for this run (--append-system-prompt-file, --mcp-config), - // removed when it ends. - std::vector claudeTempFiles_; - bool claudeResumed_ = false; // this run passed --resume - bool claudeRetriedFresh_ = false; // already retried without --resume - bool claudeResultSeen_ = false; - bool claudeIsError_ = false; - std::string claudeErrorText_; - size_t claudeTurnHistoryStart_ = 0; // history_ size after the user message - // Top-level assistant message being assembled from per-block "assistant" - // events (same API message id). Flushed into history_ before its tool - // results, or when the next message starts. - std::string claudeMsgId_; - nlohmann::json claudePendingMsg_; - std::string claudeStreamMsgId_; // message being streamed now - std::set claudeStreamedMsgIds_; // messages whose text streamed - // tool_use id -> (name, compact JSON input), for rendering the result. - std::map> claudeToolUses_; - - // Spawn the CLI for the user message at the end of history_. `fresh` + void DoSendActualRequest(Thread& t); + + // Spawn the CLI for the user message at the end of t.history. `fresh` // skips --resume (used after Claude Code reports the session is gone). - void StartClaudeTurn(bool fresh = false); - void OnClaudeData(std::string_view chunk); - void OnClaudeDone(ClaudeProcessResult res); - void HandleClaudeEvent(const nlohmann::json& ev); - void FlushClaudePendingMessage(); + void StartClaudeTurn(Thread& t, bool fresh = false); + void OnClaudeData(Thread& t, std::string_view chunk); + void OnClaudeDone(Thread& t, ClaudeProcessResult res); + void HandleClaudeEvent(Thread& t, const nlohmann::json& ev); + void FlushClaudePendingMessage(Thread& t); // Show a Claude Code failure (with a sign-in hint for auth errors), drop // the user message if the turn recorded nothing, and end the turn. - void FailClaudeTurn(const std::string& detail); + void FailClaudeTurn(Thread& t, const std::string& detail); // Italic notice when a turn goes to a different model than the last one. - void RenderModelSwitchNotice(const std::string& modelId); + void RenderModelSwitchNotice(Thread& t, const std::string& modelId); // Debug log (debug builds only). DebugWindow* debugWindow_ = nullptr; @@ -520,57 +710,67 @@ class ChatFrame : public wxFrame { // Returns true if a summary request was kicked off (caller should // return immediately); false if history fits and the caller should // proceed to send the normal request. - bool MaybeCompactThenSend(); - // Index into history_ marking the start of the verbatim tail (the most + bool MaybeCompactThenSend(Thread& t); + // Index into t.history marking the start of the verbatim tail (the most // recent ~kTailBudgetTokens of non-summary, non-system, non-compacted // messages). Messages before this index are the head to summarize. - int SelectTailSplit() const; + static int SelectTailSplit(const Thread& t); // Fires the summary stream request. The head [0..splitIdx) of - // history_ is summarized into a single user-role message. - void RunSummaryThenSend(int splitIdx); + // t.history is summarized into a single user-role message. + void RunSummaryThenSend(Thread& t, int splitIdx); // Summary stream callbacks (separate from the main OnStreamData / // OnStreamDone — the summary response is private to the compaction // path and never lands in canvas blocks). - void OnSummaryStreamData(std::string_view chunk); - void OnSummaryStreamDone(WebResponse resp); - // Replace history_[0..splitIdx) with a single isSummary user message + void OnSummaryStreamData(Thread& t, std::string_view chunk, bool streamEnded = false); + void OnSummaryStreamDone(Thread& t, WebResponse resp); + // Replace t.history[0..splitIdx) with a single isSummary user message // carrying `summary`, persist, then resume the real request. Falls // back to a generic "context dropped" notice if `success` is false so // the next request still fits. - void ApplyCompaction(bool success, const std::string& summary, + void ApplyCompaction(Thread& t, bool success, const std::string& summary, const std::string& error); // Layer 3: aggressive compaction after a provider context-overflow 400. // Keeps only the most recent user turn verbatim. Returns false if there // is nothing to compact. - bool ForceCompactForOverflow(); + bool ForceCompactForOverflow(Thread& t); // After State_Completed: if any tool_calls were accumulated, dispatch them // and continue; otherwise finalize the turn. freeModelStall renders the // friendly "free model stopped responding" notice instead of a generic // error (same history rollback + finalize path). - void HandleCompletion(const wxString& errorIfFailed, + void HandleCompletion(Thread& t, const wxString& errorIfFailed, bool freeModelStall = false); // wasCancelledOrError=true (any failure path) lands us in idle-queue // mode if the queue is non-empty so the user decides whether to // Continue. Default false (natural completion) auto-dispatches. - void FinalizeTurn(bool wasCancelledOrError = false); + void FinalizeTurn(Thread& t, bool wasCancelledOrError = false); - void RenderToolBlock(const std::string& name, + // Canvas output for thread `t`. These draw only while `t` is shown, so a + // background thread's turn updates its data and nothing else. + // `childId`: the subagent thread that ran the call, linked below it. + void RenderToolBlock(Thread& t, const std::string& name, const std::string& argsJson, - const std::string& result); - void RenderErrorBlock(const wxString& msg); + const std::string& result, + const std::string& childId = std::string()); + void RenderErrorBlock(Thread& t, const wxString& msg); + // Adds a display-only block to `t` (see Thread::localBlocks), drawn now + // when `t` is shown. + void AddLocalBlock(Thread& t, Block b); + // A failed turn's notice: drawn now when `t` is shown, otherwise kept on + // the thread (with the prompt when it was rolled back) until viewed. + void ShowTurnFailure(Thread& t, Block notice, bool promptRolledBack); // Inline notice for when the no-key free model stalls: explains it's the // free tier, recommends DeepSeek, and links to Settings. - void RenderFreeModelStallNotice(); + static Block FreeModelStallNotice(); // Append a thinking block to the canvas. Always starts collapsed. // Empty text is a no-op (a model can advertise reasoning_content but // send nothing). - void RenderThinkingBlock(const wxString& text); + void RenderThinkingBlock(Thread& t, const wxString& text); // Emit the pending thinking block (if any) for the current completion - // round and set thinkingEmitted_ true. Idempotent. - void EmitPendingThinking(); + // round and set t.thinkingEmitted true. Idempotent. + void EmitPendingThinking(Thread& t); // Called as reasoning deltas arrive: adds the collapsed live Thinking // block on the first one, then (throttled, and only while the user has it // expanded) refreshes its text. No-op once the round's thinking is emitted. - void UpdateLiveThinking(); + void UpdateLiveThinking(Thread& t); }; diff --git a/src/main.cpp b/src/main.cpp index 5995bec..c5f8240 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -63,9 +63,9 @@ LONG WINAPI SehCrashFilter(EXCEPTION_POINTERS* ep) { namespace fs = std::filesystem; using json = nlohmann::json; -// One-shot --reindex: walk every session file on disk and re-index its -// turns into the FTS5 database. Safe to re-run (RebuildSession deletes and -// re-inserts the session's rows). +// One-shot --reindex: walk every session file on disk (each thread has its +// own) and re-index its turns, title and parent into the FTS5 database. Safe +// to re-run (RebuildSession deletes and re-inserts the session's rows). static int RunReindex() { MemoryDB memory; if (!memory.Open(MemoryDB::DefaultPath())) { @@ -102,7 +102,8 @@ static int RunReindex() { const json& messages = j.contains("messages") && j["messages"].is_array() ? j["messages"] : json::array(); - if (!memory.RebuildSession(sessionId, cwd, messages, timestamp)) { + if (!memory.RebuildSession(sessionId, cwd, j.value("title", ""), + j.value("parent", ""), messages, timestamp)) { std::fprintf(stderr, " skip (index error): %s\n", entry.path().c_str()); skipped++; diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp index 38e1281..860606f 100644 --- a/src/mcp_server.cpp +++ b/src/mcp_server.cpp @@ -2,6 +2,7 @@ #ifndef _WIN32 #include +#include #include #include #include @@ -50,8 +51,12 @@ void MCPServer::ServerLoop() { addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + // GRIT_MCP_PORT pins the port (lets tests run several instances). + int firstPort = 8765, lastPort = 8770; + if (const char* env = std::getenv("GRIT_MCP_PORT"); env && *env) + firstPort = lastPort = std::atoi(env); bool bound = false; - for (int p = 8765; p <= 8770; ++p) { + for (int p = firstPort; p <= lastPort; ++p) { addr.sin_port = htons(p); if (bind(serverFd_, (struct sockaddr*)&addr, sizeof(addr)) == 0) { port_ = p; @@ -60,7 +65,7 @@ void MCPServer::ServerLoop() { } } if (!bound) { - fprintf(stderr, "MCP: bind failed on ports 8765-8770\n"); + fprintf(stderr, "MCP: bind failed on ports %d-%d\n", firstPort, lastPort); return; } @@ -185,11 +190,15 @@ json MCPServer::HandleRequest(const json& request) { if (!cb_.play) return err(-32603, "not ready"); return okResult(cb_.play()); } + if (method == "setInput") { + if (!cb_.setInput) return err(-32603, "not ready"); + return okResult(cb_.setInput(params.value("text", ""))); + } if (method == "togglePanel") { if (!cb_.togglePanel) return err(-32603, "not ready"); std::string panel = params.value("panel", std::string()); - if (panel != "editor" && panel != "import") - return err(-32602, "'panel' must be \"editor\" or \"import\""); + if (panel != "editor" && panel != "import" && panel != "threads") + return err(-32602, "'panel' must be \"editor\", \"import\" or \"threads\""); return okResult(cb_.togglePanel(panel)); } if (method == "getPanes") { @@ -220,6 +229,20 @@ json MCPServer::HandleRequest(const json& request) { if (!cb_.newSession) return err(-32603, "not ready"); return okResult(cb_.newSession()); } + if (method == "listThreads") { + if (!cb_.listThreads) return err(-32603, "not ready"); + return okResult(cb_.listThreads()); + } + if (method == "newThread") { + if (!cb_.newThread) return err(-32603, "not ready"); + return okResult(cb_.newThread(params.value("cwd", ""))); + } + if (method == "selectThread") { + if (!cb_.selectThread) return err(-32603, "not ready"); + std::string id = params.value("id", ""); + if (id.empty()) return err(-32602, "missing 'id' param"); + return okResult(cb_.selectThread(id)); + } if (method == "setModel") { if (!cb_.setModel) return err(-32603, "not ready"); if (!params.contains("index") || !params["index"].is_number_integer()) diff --git a/src/mcp_server.h b/src/mcp_server.h index b378dbd..9ab84fb 100644 --- a/src/mcp_server.h +++ b/src/mcp_server.h @@ -12,7 +12,8 @@ // onto the GUI thread via wxFrame::CallAfter when they need to mutate UI. // // Wire protocol: newline-delimited JSON-RPC 2.0. -// Default port: 8765. If unavailable, falls back to 8766..8770. +// Default port: 8765. If unavailable, falls back to 8766..8770; the +// GRIT_MCP_PORT environment variable pins one port instead. struct MCPCallbacks { // Returns a JSON status object. Should include at least // {streaming: bool, blocks: int}. @@ -53,6 +54,16 @@ struct MCPCallbacks { // Interactive new-session flow goes through the directory dialog instead. std::function newSession; + // Threads. listThreads returns {threads: [{id, cwd, title, lastUsed, + // loaded, running, failed, unseen}, ...], activeId}; `failed` marks a + // background failure not yet viewed. newThread adds an empty + // thread in `cwd` (the active folder when empty) and makes it active; + // selectThread makes thread `id` active. Both return {ok, ...}; every + // other method acts on the active thread. + std::function listThreads; + std::function newThread; + std::function selectThread; + // Set the active model by dropdown index (0=Kilo Free, 1=DeepSeek // Flash, 2=DeepSeek Pro). Persists via wxConfig. Returns {ok, modelIndex}. // Test hook for driving the provider switch programmatically. @@ -89,12 +100,16 @@ struct MCPCallbacks { // {started: false, reason: "streaming"} while a turn is in flight. std::function play; - // Toggle a side panel ("editor" or "import") as its toolbar button would, - // then return getPanes(). + // Put `text` in the input box without sending it, as typing would. + std::function setInput; + + // Toggle a side panel ("editor", "import" or "threads") as its toolbar + // button would, then return getPanes(). std::function togglePanel; // Pixel widths of the window client area and each visible pane: - // {window, import, chat, editor} (0 for a hidden pane). + // {window, threads, import, chat, editor} (0 for a hidden pane), and the + // thread pane's rows (threadRows: id, title, detail, child). std::function getPanes; // Scroll the chat so block `index` is at the top; returns {ok}. diff --git a/src/mcp_stdio.cpp b/src/mcp_stdio.cpp index 4b57c8e..1458b94 100644 --- a/src/mcp_stdio.cpp +++ b/src/mcp_stdio.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -31,8 +32,10 @@ static json SearchToolDef() { "no clearly relevant hits, STOP and answer from what you have (or " "ask the user) - do not fire speculative variant queries. " "Query: 2-5 keywords. Returns short snippets (~20-token window) " - "with session_id, turn_index, and full_chars. Follow up with " - "grit_history_fetch to read the full turn + surrounding context."}, + "with the thread title, session_id, turn_index, and full_chars. " + "Follow up with grit_history_fetch to read the full turn + " + "surrounding context. scope \"folder\" keeps only conversations " + "in the current working directory: the project's other threads."}, {"inputSchema", { {"type", "object"}, {"properties", { @@ -44,6 +47,11 @@ static json SearchToolDef() { {"type", "integer"}, {"description", "Max results (default 5, max 20)."}, {"default", 5} + }}, + {"scope", { + {"type", "string"}, + {"enum", json::array({"all", "folder"})}, + {"description", "\"all\" (default): every project. \"folder\": only the current working directory's threads."} }} }}, {"required", json::array({"query"})} @@ -173,28 +181,13 @@ int RunMcpStdioServer(const std::string& runProjectCwd) { text = DispatchTool(name, args, nullptr, nullptr, runProjectCwd, false); } else if (!memory.IsOpen()) { text = "Memory index is not available."; - } else if (name == "grit_history_search") { - std::string query = args.value("query", ""); - int limit = args.value("limit", 5); - if (limit < 1) limit = 1; - if (limit > 20) limit = 20; - if (query.empty()) { - text = "Error: 'query' is required."; - } else { - auto hits = memory.Search(query, limit, ""); - text = MemoryDB::FormatSearchHits(hits); - } - } else if (name == "grit_history_fetch") { - std::string sid = args.value("session_id", ""); - int ti = args.value("turn_index", -1); - int before = args.value("before", 2); - int after = args.value("after", 2); - if (sid.empty() || ti < 0) { - text = "Error: 'session_id' and 'turn_index' are required."; - } else { - auto hits = memory.Fetch(sid, ti, before, after); - text = MemoryDB::FormatFetchHits(hits); - } + } else if (name == "grit_history_search" || name == "grit_history_fetch") { + // The client starts this server in its project folder, which + // is the folder of scope "folder". The calling conversation + // isn't known here, so nothing is excluded. + std::error_code ec; + const std::string cwd = std::filesystem::current_path(ec).string(); + text = DispatchTool(name, args, nullptr, &memory, cwd, true); } else { std::cout << MakeError(id, -32601, "unknown tool: " + name).dump() << "\n"; continue; diff --git a/src/memory.cpp b/src/memory.cpp index 1846734..eadf5c9 100644 --- a/src/memory.cpp +++ b/src/memory.cpp @@ -88,6 +88,12 @@ bool MemoryDB::EnsureSchema() { // // tokenize=porter+unicode61: porter stemming so "compacting"/"compact" // collide; unicode61 normalizes case + diacritics. + // + // `threads` (title and parent per session id) was added with threads. + // It is a separate plain table so `turns` keeps the exact schema 0.5.x + // creates and reads; old versions ignore it. Sessions indexed before it + // existed (or by 0.5.x) have no row until their next save or a + // --reindex, and their hits just show no title. return Exec( "CREATE VIRTUAL TABLE IF NOT EXISTS turns USING fts5(" " cwd, text," @@ -96,6 +102,12 @@ bool MemoryDB::EnsureSchema() { " turn_index UNINDEXED," " timestamp UNINDEXED," " tokenize = 'porter unicode61'" + ")") + && Exec( + "CREATE TABLE IF NOT EXISTS threads(" + " session_id TEXT PRIMARY KEY," + " title TEXT NOT NULL DEFAULT ''," + " parent TEXT NOT NULL DEFAULT ''" ")"); } @@ -178,6 +190,8 @@ static std::string TextForMessage(const nlohmann::json& m) { bool MemoryDB::RebuildSession(const std::string& session_id, const std::string& cwd, + const std::string& title, + const std::string& parent, const nlohmann::json& messages, const std::string& timestamp) { PERF_SCOPE("MemoryDB::RebuildSession"); @@ -195,6 +209,18 @@ bool MemoryDB::RebuildSession(const std::string& session_id, sqlite3_finalize(del); } } + { + sqlite3_stmt* thr = nullptr; + if (sqlite3_prepare_v2(db_, + "INSERT OR REPLACE INTO threads(session_id, title, parent) VALUES (?, ?, ?)", + -1, &thr, nullptr) == SQLITE_OK) { + sqlite3_bind_text(thr, 1, session_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(thr, 2, title.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(thr, 3, parent.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_step(thr); + sqlite3_finalize(thr); + } + } sqlite3_stmt* ins = nullptr; if (sqlite3_prepare_v2(db_, @@ -257,6 +283,9 @@ std::string MemoryDB::FormatSearchHits(const std::vector& hits) { std::string entry; entry += "### [" + std::to_string(i + 1) + "] " + h.cwd + " (" + h.role + ", " + h.timestamp + ")\n"; + if (!h.title.empty() || h.subagent) + entry += "thread: " + (h.title.empty() ? "(untitled)" : h.title) + + (h.subagent ? " (subagent)" : "") + "\n"; entry += "session_id: " + h.session_id + " turn_index: " + std::to_string(h.turn_index) + " full_chars: " + std::to_string(h.full_chars) + "\n"; @@ -304,7 +333,8 @@ std::string MemoryDB::FormatFetchHits(const std::vector& hits) { std::vector MemoryDB::Search(const std::string& query, int limit, - const std::string& exclude_session_id) { + const std::string& exclude_session_id, + const std::string& cwd) { std::vector out; std::lock_guard lk(mu_); if (!db_ || query.empty()) return out; @@ -314,14 +344,16 @@ std::vector MemoryDB::Search(const std::string& query, // can see what actually matched. length(text) exposes the full turn size // so the agent can decide whether grit_history_fetch is worth it. std::string sql = - "SELECT cwd, role, " + "SELECT turns.cwd, role, " " snippet(turns, 1, '[', ']', '…', 20) AS snip, " - " session_id, turn_index, timestamp, " + " turns.session_id, turn_index, timestamp, " " bm25(turns) AS score, " - " length(text) AS full_chars " - "FROM turns " + " length(text) AS full_chars, " + " threads.title, threads.parent " + "FROM turns LEFT JOIN threads ON threads.session_id = turns.session_id " "WHERE turns MATCH ? "; - if (!exclude_session_id.empty()) sql += "AND session_id != ? "; + if (!exclude_session_id.empty()) sql += "AND turns.session_id != ? "; + if (!cwd.empty()) sql += "AND turns.cwd = ? "; sql += "ORDER BY bm25(turns) LIMIT ?"; sqlite3_stmt* stmt = nullptr; @@ -331,6 +363,8 @@ std::vector MemoryDB::Search(const std::string& query, sqlite3_bind_text(stmt, idx++, query.c_str(), -1, SQLITE_TRANSIENT); if (!exclude_session_id.empty()) sqlite3_bind_text(stmt, idx++, exclude_session_id.c_str(), -1, SQLITE_TRANSIENT); + if (!cwd.empty()) + sqlite3_bind_text(stmt, idx++, cwd.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, idx, limit > 0 ? limit : 5); while (sqlite3_step(stmt) == SQLITE_ROW) { @@ -347,6 +381,8 @@ std::vector MemoryDB::Search(const std::string& query, h.timestamp = sv(5); h.score = sqlite3_column_double(stmt, 6); h.full_chars = sqlite3_column_int(stmt, 7); + h.title = sv(8); + h.subagent = !sv(9).empty(); out.push_back(std::move(h)); } sqlite3_finalize(stmt); diff --git a/src/memory.h b/src/memory.h index ab4c53a..e7ca937 100644 --- a/src/memory.h +++ b/src/memory.h @@ -25,6 +25,8 @@ class MemoryDB { double score = 0; // BM25 score (lower = better); 0 for Fetch rows int full_chars = 0; // byte size of the full turn text — lets the // agent decide whether a Fetch is worth it + std::string title; // thread title (Search only; empty if unknown) + bool subagent = false; // the thread is a subagent's (Search only) }; MemoryDB() = default; @@ -53,9 +55,12 @@ class MemoryDB { // Bulk re-index every turn of a session. Wraps a transaction around // ClearSession + IndexTurn calls. `messages` is a JSON array of // {role, content, ...} entries (gritcode's wire format). System - // messages and tool messages with no content are skipped. + // messages and tool messages with no content are skipped. The thread's + // title and parent go into the `threads` side table. bool RebuildSession(const std::string& session_id, const std::string& cwd, + const std::string& title, + const std::string& parent, const nlohmann::json& messages, const std::string& timestamp); @@ -64,10 +69,12 @@ class MemoryDB { // unmodified. Bad queries (FTS5 syntax errors) return an empty vector. // If exclude_session_id is non-empty, rows with that session_id are // excluded from results — used to keep the agent from re-surfacing - // turns from the very session it's currently in. + // turns from the very session it's currently in. A non-empty `cwd` + // keeps only that folder's turns (all of its threads). std::vector Search(const std::string& query, int limit, - const std::string& exclude_session_id); + const std::string& exclude_session_id, + const std::string& cwd = {}); // Return a window of consecutive turns around a given (session_id, // turn_index). Full turn text is returned (no snippetting) — this is the diff --git a/src/preferences.cpp b/src/preferences.cpp index f1b8aac..78cce14 100644 --- a/src/preferences.cpp +++ b/src/preferences.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #if wxUSE_SECRETSTORE #include #else @@ -21,6 +22,7 @@ const wxString kUsername = "api_key"; const char* kModelIndexKey = "/UI/LastModelIndex"; const char* kModelExplicitKey = "/UI/ModelExplicit"; const char* kEnableGritKey = "/UI/EnableGritHistory"; +const char* kLeftThreadsKey = "/UI/LeftPaneThreads"; const char* kReasoningEffortKey = "/DeepSeek/ReasoningEffort"; const char* kClaudeEffortKey = "/Claude/Effort"; @@ -123,6 +125,18 @@ bool Preferences::GetEnableGritHistory() { return v; } +bool Preferences::GetLeftPaneThreads() { + bool v = false; + wxConfigBase::Get()->Read(kLeftThreadsKey, &v, false); + return v; +} + +void Preferences::SetLeftPaneThreads(bool threads) { + auto* cfg = wxConfigBase::Get(); + cfg->Write(kLeftThreadsKey, threads); + cfg->Flush(); +} + void Preferences::SetEnableGritHistory(bool enabled) { auto* cfg = wxConfigBase::Get(); if (!cfg) return; @@ -133,6 +147,12 @@ void Preferences::SetEnableGritHistory(bool enabled) { // ---- OS keyring (wxSecretStore / libsecret) ---- wxString Preferences::GetApiKey(Provider p) { + // Test/dev hook for headless instances with no keyring (the hidden test + // launcher gives them a private D-Bus): a key from the environment, + // never stored. + if (const char* key = std::getenv("GRIT_DEEPSEEK_API_KEY"); + key && *key && p == Provider::DeepSeek) + return wxString::FromUTF8(key); #if wxUSE_SECRETSTORE wxSecretStore store = wxSecretStore::GetDefault(); if (!store.IsOk()) return wxString(); diff --git a/src/preferences.h b/src/preferences.h index 8b6f9be..eb43f7d 100644 --- a/src/preferences.h +++ b/src/preferences.h @@ -40,6 +40,11 @@ class Preferences { static bool GetEnableGritHistory(); static void SetEnableGritHistory(bool enabled); + // Whether the left pane shows the thread list rather than the session + // reference. Defaults to false. + static bool GetLeftPaneThreads(); + static void SetLeftPaneThreads(bool threads); + // DeepSeek reasoning effort, sent as `reasoning_effort` on every DeepSeek // chat request: "high" (DeepSeek's own default, and ours) or "max". // Anything else stored in the config reads back as "high". diff --git a/src/project_brief.cpp b/src/project_brief.cpp new file mode 100644 index 0000000..ac12bbc --- /dev/null +++ b/src/project_brief.cpp @@ -0,0 +1,183 @@ +#include "project_brief.h" +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr size_t kMaxEntries = 150; +constexpr size_t kMaxFileChars = 12000; + +const char* const kBriefPrompt = + "You write the project brief for a folder a coding assistant works in. " + "Each new conversation in this folder starts with the brief instead of the " + "history of earlier conversations, so it should tell the assistant what it " + "needs to get going: what the project is and does, how it is laid out, how " + "to build, run and test it, the conventions to follow, and what is being " + "worked on now (the thread titles hint at that). Write 2 to 4 short " + "paragraphs, at most about 250 words, as plain text or light markdown " + "without headings. Use only facts from the material; leave out what it " + "doesn't tell. Output only the brief."; + +std::string Capped(std::string s, size_t max) { + if (s.size() > max) s = s.substr(0, max) + "\n[truncated]"; + return s; +} + +std::string Trimmed(const std::string& s) { + const char* ws = " \t\r\n"; + const size_t first = s.find_first_not_of(ws); + if (first == std::string::npos) return std::string(); + return s.substr(first, s.find_last_not_of(ws) - first + 1); +} + +WebRequestSpec BriefRequest(const BriefModel& model, const std::string& material) { + nlohmann::json req; + req["model"] = model.model; + req["stream"] = true; + req["max_tokens"] = 2000; + if (model.deepseek) req["thinking"] = {{"type", "disabled"}}; + req["messages"] = nlohmann::json::array({ + {{"role", "system"}, {"content", kBriefPrompt}}, + {{"role", "user"}, {"content", material}}, + }); + WebRequestSpec spec; + spec.url = model.url; + spec.method = "POST"; + spec.body = req.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); + spec.headers.push_back({"Accept", "text/event-stream"}); + if (!model.apiKey.empty()) + spec.headers.push_back({"Authorization", "Bearer " + model.apiKey}); + spec.idleTimeoutSeconds = 60; + return spec; +} + +// The text of one streamed chat completion event, empty for anything else. +std::string DeltaText(const std::string& payload) { + try { + const auto j = nlohmann::json::parse(payload); + const auto& delta = j.at("choices").at(0).at("delta"); + if (delta.contains("content") && delta["content"].is_string()) + return delta["content"].get(); + } catch (...) { + // [DONE], keepalives and other shapes carry no text. + } + return std::string(); +} + +// The message of a JSON error body ({"error": {"message": ...}}), else the +// body itself, shortened. +std::string ErrorDetail(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + const auto& err = j.at("error"); + if (err.is_string()) return err.get(); + return err.at("message").get(); + } catch (...) { + } + return Capped(Trimmed(body), 300); +} + +} // namespace + +std::string BriefMaterial(const std::string& cwd, const std::string& agents, + const std::vector& threadTitles) { + std::vector names; + std::string readme; + std::error_code ec; + for (const auto& e : fs::directory_iterator(cwd, ec)) { + const std::string name = e.path().filename().string(); + if (name.starts_with('.')) continue; + const bool dir = e.is_directory(ec); + names.push_back(dir ? name + "/" : name); + std::string stem = e.path().stem().string(); + std::ranges::transform(stem, stem.begin(), ::tolower); + if (!dir && stem == "readme" && (readme.empty() || name < readme)) readme = name; + } + std::ranges::sort(names); + + std::string out = "Folder: " + cwd + "\n\nTop-level entries:\n"; + for (size_t i = 0; i < names.size() && i < kMaxEntries; ++i) out += names[i] + "\n"; + if (names.size() > kMaxEntries) out += "[and more]\n"; + if (!readme.empty()) { + std::ifstream f(cwd + "/" + readme, std::ios::binary); + std::ostringstream ss; + ss << f.rdbuf(); + out += "\n" + readme + ":\n" + Capped(ss.str(), kMaxFileChars) + "\n"; + } + if (!agents.empty()) + out += "\nAGENTS.md instructions:\n" + Capped(agents, kMaxFileChars) + "\n"; + if (!threadTitles.empty()) { + out += "\nTitles of the folder's conversation threads, most recent first:\n"; + for (const auto& t : threadTitles) out += "- " + t + "\n"; + } + return out; +} + +BriefRun WriteBrief(wxEvtHandler* target, const BriefModel& model, const std::string& cwd, + const std::string& material, + std::function done) { + BriefRun run; + if (!model.claudeExe.empty()) { + // One print-mode run with no tools (the material is all it needs) + // that isn't kept among the user's Claude sessions. + ClaudeRunSpec spec; + spec.executable = model.claudeExe; + spec.cwd = cwd; + spec.args = {"-p", "--output-format", "json", "--model", model.model, + "--system-prompt", kBriefPrompt, "--tools", "", + "--no-session-persistence", "--strict-mcp-config"}; + spec.stdinPayload = material; + auto out = std::make_shared(); + run.claude = ClaudeAgentProcess( + target, std::move(spec), [out](std::string_view chunk) { *out += chunk; }, + [out, done = std::move(done)](ClaudeProcessResult res) { + if (!res.started) { + done({}, res.error); + return; + } + // --output-format json: one result object. + std::string brief, error; + try { + const auto j = nlohmann::json::parse(*out); + if (j.value("is_error", false)) + error = "Claude Code: " + Capped(j.value("result", std::string()), 300); + else + brief = Trimmed(j.value("result", std::string())); + } catch (...) { + error = "Claude Code exited with status " + std::to_string(res.exitCode); + if (std::string tail = Trimmed(res.stderrTail); !tail.empty()) + error += ": " + Capped(tail, 300); + } + if (error.empty() && brief.empty()) error = "the model returned no text"; + done(std::move(brief), std::move(error)); + }); + return run; + } + // Streamed events, and the raw body when the request fails. + auto sse = std::make_shared(); + auto text = std::make_shared(); + auto take = [sse, text](std::string_view chunk, bool ended) { + for (const std::string& payload : TakeSseEvents(*sse, chunk, ended)) + *text += DeltaText(payload); + }; + run.request = StreamingWebRequest( + target, BriefRequest(model, material), + [take](std::string_view chunk) { take(chunk, false); }, + [take, sse, text, done = std::move(done)](WebResponse resp) { + if (!resp.ok) { + done({}, resp.error + (sse->empty() ? "" : ": " + ErrorDetail(*sse))); + return; + } + take({}, true); + std::string brief = Trimmed(*text); + if (brief.empty()) done({}, "the model returned no text"); + else done(std::move(brief), {}); + }); + return run; +} diff --git a/src/project_brief.h b/src/project_brief.h new file mode 100644 index 0000000..33b5c5a --- /dev/null +++ b/src/project_brief.h @@ -0,0 +1,41 @@ +#pragma once +#include "claude_agent.h" +#include "streaming_web_request.h" +#include +#include +#include +#include +#include + +// A project brief is a few paragraphs on what a folder's project is, its +// layout and conventions, and the current work. A model writes it when the +// user starts a new thread in a folder without one; every later new thread +// there gets it in its system prompt (SessionStore keeps it). + +// The model that writes it: an OpenAI-style chat completions endpoint, or +// `model` through the user's Claude Code CLI when `claudeExe` is set. +struct BriefModel { + std::string url; + std::string model; + std::string apiKey; // empty: no Authorization header + bool deepseek = false; // DeepSeek: thinking off, this is a writing task + std::string claudeExe; // FindClaudeExecutable() + wxString label; // named in the notices +}; + +// A brief being written: one of the two runs. Destroying it cancels. +struct BriefRun { + StreamingWebRequest request; + ClaudeAgentProcess claude; +}; + +// What the model writes the brief from: the folder's top-level entries, its +// README, the AGENTS.md text and the titles of the folder's threads. +std::string BriefMaterial(const std::string& cwd, const std::string& agents, + const std::vector& threadTitles); + +// Asks `model` for the brief of `cwd`. `done` runs on `target` with the +// brief (trimmed), or an empty brief and why it failed. +BriefRun WriteBrief(wxEvtHandler* target, const BriefModel& model, const std::string& cwd, + const std::string& material, + std::function done); diff --git a/src/run_config_store.cpp b/src/run_config_store.cpp index b0a27d3..60c63eb 100644 --- a/src/run_config_store.cpp +++ b/src/run_config_store.cpp @@ -2,9 +2,16 @@ #include #include #include +#include namespace fs = std::filesystem; +namespace { +// Threads run tools at once, so Get/Set/Forget go through this lock: without +// it two read-modify-writes at once lose one of the entries. +std::mutex gFileMutex; +} // namespace + std::string RunConfigStore::StoragePath() { // Match the DataRoot() convention from session_store.cpp: // $XDG_DATA_HOME/gritcode or $HOME/.local/share/gritcode. @@ -53,11 +60,21 @@ void RunConfigStore::Save(const std::map& map) { {"lastUsed", c.lastUsed} }; } - std::ofstream f(path); - if (f.is_open()) f << j.dump(2); + // Write a temp file and rename it over the old one, so a reader never + // sees a half-written file (another gritcode process may read it). + std::string tmp = path + ".tmp"; + { + std::ofstream f(tmp); + if (!f.is_open()) return; + f << j.dump(2); + if (!f) return; + } + std::error_code ec; + fs::rename(tmp, path, ec); } std::optional RunConfigStore::Get(const std::string& cwd) { + std::lock_guard lock(gFileMutex); auto map = Load(); auto it = map.find(cwd); if (it != map.end()) return it->second; @@ -66,6 +83,7 @@ std::optional RunConfigStore::Get(const std::string& cwd void RunConfigStore::Set(const std::string& cwd, const std::string& command, const std::string& discoveredBy) { + std::lock_guard lock(gFileMutex); auto map = Load(); Config c; c.command = command; @@ -80,6 +98,7 @@ void RunConfigStore::Set(const std::string& cwd, const std::string& command, } void RunConfigStore::Forget(const std::string& cwd) { + std::lock_guard lock(gFileMutex); auto map = Load(); map.erase(cwd); Save(map); diff --git a/src/run_config_store.h b/src/run_config_store.h index 61f18f2..9451c5f 100644 --- a/src/run_config_store.h +++ b/src/run_config_store.h @@ -16,12 +16,6 @@ class RunConfigStore { std::string lastUsed; // ISO-8601 timestamp }; - // Load from disk. Returns an empty map if the file doesn't exist. - static std::map Load(); - - // Save to disk. - static void Save(const std::map& map); - // Get the config for a project dir. Returns nullopt if none. static std::optional Get(const std::string& cwd); @@ -33,4 +27,12 @@ class RunConfigStore { static void Forget(const std::string& cwd); static std::string StoragePath(); + +private: + // Load from disk. Returns an empty map if the file doesn't exist. + // Callers hold the file lock (see run_config_store.cpp). + static std::map Load(); + + // Save to disk. Callers hold the file lock. + static void Save(const std::map& map); }; diff --git a/src/session_store.cpp b/src/session_store.cpp index 12f06e0..257c722 100644 --- a/src/session_store.cpp +++ b/src/session_store.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include namespace fs = std::filesystem; @@ -33,19 +35,24 @@ std::string DataRoot() { #endif } -void AtomicWrite(const std::string& path, const std::string& content) { +bool AtomicWrite(const std::string& path, const std::string& content) { std::string tmp = path + ".tmp"; { std::ofstream f(tmp, std::ios::binary | std::ios::trunc); f << content; + f.close(); + if (!f) return false; } std::error_code ec; fs::rename(tmp, path, ec); if (ec) { // rename can fail across mount points; copy + remove as fallback. - fs::copy_file(tmp, path, fs::copy_options::overwrite_existing, ec); + std::error_code copyEc; + fs::copy_file(tmp, path, fs::copy_options::overwrite_existing, copyEc); fs::remove(tmp, ec); + return !copyEc; } + return true; } } // namespace @@ -70,10 +77,17 @@ void SessionStore::Init() { ReadIndex(); } -bool SessionStore::Load(const std::string& cwd, +std::string SessionStore::NewThreadId() { + static std::mt19937_64 rng{std::random_device{}()}; + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", (unsigned long long)rng()); + return std::string(buf, 16); +} + +bool SessionStore::Load(const std::string& id, std::vector& outHistory) const { PERF_SCOPE("SessionStore::Load"); - std::string path = sessionsDir_ + "/" + IdForCwd(cwd) + ".json"; + std::string path = sessionsDir_ + "/" + id + ".json"; std::ifstream f(path); if (!f.good()) return false; nlohmann::json j; @@ -101,64 +115,60 @@ std::string SessionStore::NowIso() { return buf; } -void SessionStore::Save(const std::string& cwd, - const std::vector& history) { +void SessionStore::Save(Entry e, const std::vector& history) { PERF_SCOPE("SessionStore::Save"); - std::string lastUsed = NowIso(); - WriteSessionFile(cwd, history, lastUsed); - UpdateIndex(cwd, lastUsed); + e.lastUsed = NowIso(); + WriteSessionFile(e, history); + UpdateIndex(e); } -void SessionStore::WriteSessionFile(const std::string& cwd, - const std::vector& history, - const std::string& lastUsed) const { - std::string id = IdForCwd(cwd); +void SessionStore::WriteSessionFile(const Entry& e, + const std::vector& history) const { nlohmann::json j; - j["id"] = id; - j["cwd"] = cwd; - j["lastUsed"] = lastUsed; + j["id"] = e.id; + j["cwd"] = e.cwd; + j["lastUsed"] = e.lastUsed; + if (!e.title.empty()) j["title"] = e.title; + if (!e.parent.empty()) j["parent"] = e.parent; j["messages"] = history; std::string body = j.dump(2, ' ', false, nlohmann::json::error_handler_t::replace); - AtomicWrite(sessionsDir_ + "/" + id + ".json", body); -} - -void SessionStore::UpdateIndex(const std::string& cwd, - const std::string& lastUsed) { - std::string id = IdForCwd(cwd); - bool found = false; - for (auto& e : entries_) { - if (e.cwd == cwd) { - e.lastUsed = lastUsed; - e.id = id; - found = true; - break; - } - } - if (!found) entries_.push_back({id, cwd, lastUsed}); + AtomicWrite(sessionsDir_ + "/" + e.id + ".json", body); +} + +void SessionStore::UpdateIndex(const Entry& e) { + auto it = std::ranges::find(entries_, e.id, &Entry::id); + if (it != entries_.end()) *it = e; + else entries_.push_back(e); SortEntries(); WriteIndex(); } -void SessionStore::SetLastActiveCwd(const std::string& cwd) { - lastActiveCwd_ = cwd; - WriteIndex(); +std::string SessionStore::BriefPath(const std::string& cwd) const { + return root_ + "/briefs/" + IdForCwd(cwd) + ".md"; } -void SessionStore::RegisterCwd(const std::string& cwd) { - std::string id = IdForCwd(cwd); - std::string lastUsed = NowIso(); - bool found = false; - for (auto& e : entries_) { - if (e.cwd == cwd) { - e.lastUsed = lastUsed; - found = true; - break; - } +std::string SessionStore::LoadBrief(const std::string& cwd) const { + std::ifstream f(BriefPath(cwd), std::ios::binary); + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +bool SessionStore::SaveBrief(const std::string& cwd, const std::string& text) const { + std::error_code ec; + if (text.empty()) { + fs::remove(BriefPath(cwd), ec); + return !ec; } - if (!found) entries_.push_back({id, cwd, lastUsed}); - SortEntries(); + fs::create_directories(root_ + "/briefs", ec); + return AtomicWrite(BriefPath(cwd), text); +} + +void SessionStore::SetLastActive(const std::string& cwd, const std::string& threadId) { + lastActiveCwd_ = cwd; + lastActiveThread_ = threadId; WriteIndex(); } @@ -172,14 +182,23 @@ void SessionStore::SortEntries() { void SessionStore::WriteIndex() const { nlohmann::json j; j["sessions"] = nlohmann::json::array(); + j["threads"] = nlohmann::json::array(); for (const auto& e : entries_) { - j["sessions"].push_back({ + nlohmann::json row = { {"id", e.id}, {"cwd", e.cwd}, {"lastUsed", e.lastUsed}, - }); + }; + if (!e.title.empty()) row["title"] = e.title; + if (e.id == IdForCwd(e.cwd)) { + j["sessions"].push_back(std::move(row)); + } else { + if (!e.parent.empty()) row["parent"] = e.parent; + j["threads"].push_back(std::move(row)); + } } if (lastActiveCwd_) j["lastActiveCwd"] = *lastActiveCwd_; + if (lastActiveThread_) j["lastActiveThread"] = *lastActiveThread_; std::string body = j.dump(2, ' ', false, nlohmann::json::error_handler_t::replace); AtomicWrite(indexPath_, body); @@ -188,28 +207,56 @@ void SessionStore::WriteIndex() const { void SessionStore::ReadIndex() { entries_.clear(); lastActiveCwd_.reset(); + lastActiveThread_.reset(); + + auto addEntry = [this](const nlohmann::json& e) { + if (!e.is_object()) return; + Entry ent; + ent.id = e.value("id", std::string{}); + ent.cwd = e.value("cwd", std::string{}); + ent.lastUsed = e.value("lastUsed", std::string{}); + ent.title = e.value("title", std::string{}); + ent.parent = e.value("parent", std::string{}); + if (ent.cwd.empty()) return; + // Backfill id if older index lacks it. + if (ent.id.empty()) ent.id = IdForCwd(ent.cwd); + if (std::ranges::find(entries_, ent.id, &Entry::id) != entries_.end()) return; + entries_.push_back(std::move(ent)); + }; std::ifstream f(indexPath_); - if (!f.good()) return; nlohmann::json j; - try { f >> j; } catch (...) { return; } - if (!j.is_object()) return; - - if (j.contains("sessions") && j["sessions"].is_array()) { - for (const auto& e : j["sessions"]) { - if (!e.is_object()) continue; - Entry ent; - ent.id = e.value("id", std::string{}); - ent.cwd = e.value("cwd", std::string{}); - ent.lastUsed = e.value("lastUsed", std::string{}); - if (ent.cwd.empty()) continue; - // Backfill id if older index lacks it. - if (ent.id.empty()) ent.id = IdForCwd(ent.cwd); - entries_.push_back(std::move(ent)); + if (f.good()) { + try { f >> j; } catch (...) { j = nullptr; } + } + if (j.is_object()) { + for (const char* key : {"sessions", "threads"}) { + if (j.contains(key) && j[key].is_array()) + for (const auto& e : j[key]) addEntry(e); } + if (j.contains("lastActiveCwd") && j["lastActiveCwd"].is_string()) + lastActiveCwd_ = j["lastActiveCwd"].get(); + if (j.contains("lastActiveThread") && j["lastActiveThread"].is_string()) + lastActiveThread_ = j["lastActiveThread"].get(); } - if (j.contains("lastActiveCwd") && j["lastActiveCwd"].is_string()) { - lastActiveCwd_ = j["lastActiveCwd"].get(); + + // Session files the index doesn't list: 0.5.x rewrites the index with + // only `sessions`, dropping `threads`. Their headers (id, cwd, lastUsed, + // title, parent) are enough; the messages are skipped while parsing. + std::error_code ec; + for (const auto& file : fs::directory_iterator(sessionsDir_, ec)) { + if (file.path().extension() != ".json") continue; + const std::string id = file.path().stem().string(); + if (std::ranges::find(entries_, id, &Entry::id) != entries_.end()) continue; + std::ifstream sf(file.path()); + auto skipMessages = [](int depth, nlohmann::json::parse_event_t ev, + nlohmann::json& v) { + return !(depth == 1 && ev == nlohmann::json::parse_event_t::key + && v == "messages"); + }; + nlohmann::json header = nlohmann::json::parse(sf, skipMessages, false); + if (header.is_object() && header.value("id", std::string{}) == id) + addEntry(header); } SortEntries(); } diff --git a/src/session_store.h b/src/session_store.h index 29a6be9..974938a 100644 --- a/src/session_store.h +++ b/src/session_store.h @@ -4,69 +4,79 @@ #include #include -// On-disk persistence for chat sessions, keyed by working directory (one -// session per folder). Storage layout mirrors gritcode: -// ~/.local/share/gritcode/sessions/.json (per-session) -// ~/.local/share/gritcode/sessions.json (index of cwds) +// On-disk persistence for chat threads. Every thread has its own session +// file; a folder's first thread uses the id derived from its cwd, so the +// one-session-per-folder layout of older versions stays readable as is: +// ~/.local/share/gritcode/sessions/.json (per-thread) +// ~/.local/share/gritcode/sessions.json (index) +// The index keeps `sessions` (each folder's first thread, all that 0.5.x +// reads) and adds `threads` for the others, which 0.5.x ignores. +// Each folder can also have a project brief, keyed like its first thread: +// ~/.local/share/gritcode/briefs/.md // Writes are atomic (temp file then rename) so a crash mid-write can't // corrupt the index or any session file. class SessionStore { public: struct Entry { - std::string id; // 16-hex hash of cwd + std::string id; // 16 hex; IdForCwd(cwd) for a folder's first thread std::string cwd; // absolute path std::string lastUsed; // ISO 8601, used for sorting (most recent first) + std::string title; // empty until the first user message + std::string parent; // parent thread id, empty for top-level threads }; // Loads the index from disk, creating storage directories if needed. void Init(); // Index entries sorted by lastUsed descending. Empty after Init() if no - // sessions have been created yet. + // threads have been created yet. const std::vector& List() const { return entries_; } - // The cwd that was active when the app was last closed, or std::nullopt - // if none recorded. + // The cwd / thread that was active when the app was last closed, or + // std::nullopt if none recorded. std::optional LastActiveCwd() const { return lastActiveCwd_; } + std::optional LastActiveThread() const { return lastActiveThread_; } - // Stable id for a cwd (16-hex hash). Same cwd always maps to same id. + // Stable id for a cwd (16-hex hash): the id of the folder's first thread. static std::string IdForCwd(const std::string& cwd); + // Random 16-hex id for an additional thread. + static std::string NewThreadId(); - // Reads the full session file for the given cwd. Returns false if the - // file is missing or malformed; outHistory is unchanged in that case. - bool Load(const std::string& cwd, + // Reads the messages of thread `id`. Returns false if the file is + // missing or malformed; outHistory is unchanged in that case. + bool Load(const std::string& id, std::vector& outHistory) const; - // Writes the full session and updates the index (lastUsed timestamp). - // Creates a new entry if this cwd hasn't been seen before. This is the - // synchronous variant used where the UI thread must block (startup seed, - // new-session create); the hot persist path splits it into the two - // methods below so the heavy parts can run on a worker thread. - void Save(const std::string& cwd, - const std::vector& history); - - // Thread-safe: writes only the per-session JSON file (no index mutation). - // Uses `lastUsed` as the recorded timestamp so the caller can keep the - // index entry consistent with the file. Safe to call from a worker. - void WriteSessionFile(const std::string& cwd, - const std::vector& history, - const std::string& lastUsed) const; - - // UI-thread only: update the in-memory index (lastUsed) + write - // sessions.json. Cheap relative to WriteSessionFile. - void UpdateIndex(const std::string& cwd, const std::string& lastUsed); - - // ISO-8601 "now" string used as lastUsed. Exposed so callers can compute - // one timestamp and feed it to both WriteSessionFile and UpdateIndex. + // Writes the full session (lastUsed = now) and updates the index. This is + // the synchronous variant used where the UI thread must block (startup + // seed, new folder); the hot persist path splits it into the two methods + // below so the heavy part can run on a worker thread. + void Save(Entry e, const std::vector& history); + + // Thread-safe: writes only the session file (no index mutation). Safe to + // call from a worker. + void WriteSessionFile(const Entry& e, + const std::vector& history) const; + + // UI-thread only: insert or update the entry + write sessions.json. + // Cheap relative to WriteSessionFile. + void UpdateIndex(const Entry& e); + + // ISO-8601 "now" string used as lastUsed. static std::string NowIso(); - // Persists the "lastActiveCwd" pointer in the index without touching - // session files. Called when the user switches sessions. - void SetLastActiveCwd(const std::string& cwd); + // The folder's project brief, empty when it has none. Saving an empty + // brief removes it; false when the file can't be written. + std::string LoadBrief(const std::string& cwd) const; + bool SaveBrief(const std::string& cwd, const std::string& text) const; + + // Where agents put the git worktrees they create: + // /worktrees//. + std::string WorktreesDir() const { return root_ + "/worktrees"; } - // Register a fresh session for cwd in the index even if no history yet. - // Lets a brand-new session show up in the dropdown immediately. - void RegisterCwd(const std::string& cwd); + // Persists the last active folder and thread in the index without + // touching session files. Called when the user switches threads. + void SetLastActive(const std::string& cwd, const std::string& threadId); private: std::string root_; // ~/.local/share/gritcode @@ -75,12 +85,16 @@ class SessionStore { std::vector entries_; std::optional lastActiveCwd_; + std::optional lastActiveThread_; + std::string BriefPath(const std::string& cwd) const; // Re-sort entries_ by lastUsed descending after a mutation. void SortEntries(); - // Persist entries_ + lastActiveCwd_ to indexPath_ atomically. + // Persist entries_ + the last-active pointers to indexPath_ atomically. void WriteIndex() const; - // Load index file; populates entries_ and lastActiveCwd_. Silent on - // missing file (treated as empty index). + // Load index file; populates entries_ and the last-active pointers. + // Silent on missing file (treated as empty index). Session files the + // index doesn't list (0.5.x rewrites it without `threads`) are added + // from their headers. void ReadIndex(); }; diff --git a/src/streaming_web_request.cpp b/src/streaming_web_request.cpp index 08c3cba..6f61145 100644 --- a/src/streaming_web_request.cpp +++ b/src/streaming_web_request.cpp @@ -277,3 +277,67 @@ bool StreamingWebRequest::IsActive() const noexcept { void StreamingWebRequest::Cancel() noexcept { if (impl_) impl_->cancelled.store(true); } + +namespace { + +// Length of the line ending at s[i] ("\n", "\r\n" or "\r"), 0 if none. +size_t LineEndAt(std::string_view s, size_t i) { + if (i >= s.size()) return 0; + if (s[i] == '\n') return 1; + if (s[i] == '\r') return i + 1 < s.size() && s[i + 1] == '\n' ? 2 : 1; + return 0; +} + +// The data of one event: its `data:` lines (one optional space after the +// colon dropped) joined by '\n'. Other fields and comments are ignored. +std::string SseEventData(std::string_view event) { + std::string data; + bool first = true; + while (!event.empty()) { + size_t end = event.find_first_of("\r\n"); + std::string_view line = event.substr(0, end); + event.remove_prefix(end == std::string_view::npos + ? event.size() : end + LineEndAt(event, end)); + if (!line.starts_with("data:")) continue; + line.remove_prefix(5); + if (line.starts_with(' ')) line.remove_prefix(1); + if (!first) data += '\n'; + data += line; + first = false; + } + return data; +} + +} // namespace + +std::vector TakeSseEvents(std::string& buf, std::string_view chunk, + bool streamEnded) { + // A blank line is at most 4 bytes ("\r\n\r\n") and `buf` held none, so + // only its last 3 bytes can start one: don't rescan the rest. + size_t pos = buf.size() < 3 ? 0 : buf.size() - 3; + buf.append(chunk); + + std::vector events; + size_t start = 0; // where the current event begins + while ((pos = buf.find_first_of("\r\n", pos)) != std::string::npos) { + size_t next = pos + LineEndAt(buf, pos); + // A line ending followed by another one is a blank line, which ends + // the event. At the end of `buf` that is still unknown: wait. + size_t blank = LineEndAt(buf, next); + if (blank == 0) { + pos = next; + continue; + } + std::string data = SseEventData(std::string_view(buf).substr(start, pos - start)); + if (!data.empty()) events.push_back(std::move(data)); + start = pos = next + blank; + } + buf.erase(0, start); + + if (streamEnded && !buf.empty()) { + std::string data = SseEventData(buf); + if (!data.empty()) events.push_back(std::move(data)); + buf.clear(); + } + return events; +} diff --git a/src/streaming_web_request.h b/src/streaming_web_request.h index 456fa5a..2da5923 100644 --- a/src/streaming_web_request.h +++ b/src/streaming_web_request.h @@ -82,3 +82,13 @@ class StreamingWebRequest { private: std::unique_ptr impl_; }; + +// Server-sent events, the chat APIs' streaming format. Appends `chunk` to +// `buf`, takes each complete event off the front and returns its data (the +// event's `data:` lines joined by '\n'); events without data, such as +// comments and keepalives, are skipped. Lines end in "\n", "\r\n" or "\r", +// and a blank line ends an event. An unfinished event stays in `buf` (so a +// non-SSE error body stays whole there) until `streamEnded`, when what is +// left counts as a last event even without the blank line. +std::vector TakeSseEvents(std::string& buf, std::string_view chunk, + bool streamEnded = false); diff --git a/src/thread_list.cpp b/src/thread_list.cpp new file mode 100644 index 0000000..73c4b63 --- /dev/null +++ b/src/thread_list.cpp @@ -0,0 +1,60 @@ +#include "thread_list.h" +#include "omarchy_theme.h" +#include +#include +#include +#include + +ThreadList::ThreadList(wxWindow* parent) + : wxVListBox(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE) {} + +void ThreadList::SetRows(std::vector rows, const std::string& activeId) { + rows_ = std::move(rows); + SetItemCount(rows_.size()); + auto active = std::ranges::find(rows_, activeId, &ThreadRow::id); + SetSelection(active != rows_.end() ? (int)(active - rows_.begin()) : wxNOT_FOUND); + RefreshAll(); +} + +wxCoord ThreadList::OnMeasureItem(size_t) const { + return 2 * GetCharHeight() + FromDIP(10); +} + +void ThreadList::OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const { + using enum ThreadRow::State; + const ThreadRow& r = rows_[n]; + const bool selected = IsSelected(n); + const wxColour bg = selected ? wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHT) + : GetBackgroundColour(); + const wxColour fg = selected ? wxSystemSettings::GetColour(wxSYS_COLOUR_HIGHLIGHTTEXT) + : GetForegroundColour(); + const wxColour dim = omarchy::Mix(fg, bg, 0.4); + + // Running is the theme's accent, a failure its red; both readable on bg. + const omarchy::Theme* theme = omarchy::Current(); + wxColour accent = theme ? theme->Get("accent") : wxColour(); + if (!accent.IsOk()) accent = wxColour(60, 140, 230); + wxColour red = theme ? theme->Get("red") : wxColour(); + if (!red.IsOk()) red = wxColour(215, 75, 60); + + dc.SetFont(GetFont()); + const int pad = FromDIP(6); + const int markW = dc.GetTextExtent(wxString::FromUTF8("\xE2\x97\x8F ")).x; // "● " + int x = rect.x + pad + (r.child ? FromDIP(14) : 0); + const int lineH = GetCharHeight(); + const int y = rect.y + (rect.height - 2 * lineH) / 2; + + if (r.state == Failed) { + dc.SetTextForeground(selected ? fg : omarchy::Readable(red, bg, 3.0)); + dc.DrawText(wxString::FromUTF8("\xE2\x9A\xA0"), x, y); // "⚠" + } else if (r.state == Running || r.state == Unseen) { + dc.SetTextForeground(selected ? fg : omarchy::Readable(accent, bg, 3.0)); + dc.DrawText(wxString::FromUTF8("\xE2\x97\x8F"), x, y); // "●" + } + x += markW; + const int textW = rect.GetRight() - pad - x; + dc.SetTextForeground(fg); + dc.DrawText(wxControl::Ellipsize(r.title, dc, wxELLIPSIZE_END, textW), x, y); + dc.SetTextForeground(dim); + dc.DrawText(wxControl::Ellipsize(r.detail, dc, wxELLIPSIZE_END, textW), x, y + lineH); +} diff --git a/src/thread_list.h b/src/thread_list.h new file mode 100644 index 0000000..0d89af4 --- /dev/null +++ b/src/thread_list.h @@ -0,0 +1,32 @@ +#pragma once +#include +#include +#include + +// One row of the thread pane. +struct ThreadRow { + // Unseen: finished while not shown, not viewed since. + enum class State { Done, Running, Unseen, Failed }; + std::string id; + wxString title; + wxString detail; // the current step while running, else when last used + State state = State::Done; + bool child = false; // a subagent, indented under its parent +}; + +// The thread pane's list: a state mark and the title, with a dim detail line +// below, in the system list colours. The selected row is the active thread. +class ThreadList : public wxVListBox { +public: + explicit ThreadList(wxWindow* parent); + + // Replace the rows and select the one with `activeId`. + void SetRows(std::vector rows, const std::string& activeId); + const std::vector& Rows() const { return rows_; } + +private: + std::vector rows_; + + void OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const override; + wxCoord OnMeasureItem(size_t n) const override; +}; diff --git a/src/tools.cpp b/src/tools.cpp index 879bd8d..85f56b4 100644 --- a/src/tools.cpp +++ b/src/tools.cpp @@ -46,10 +46,23 @@ std::string GetStringArg(const nlohmann::json& args, const char* key) { return v.get(); } +// The bash timeout: `timeout_seconds` (a number, or a numeric string as some +// models send) clamped to [1, 600], or 30 when it is missing or not positive. +int BashTimeoutSeconds(const nlohmann::json& args) { + double t = 0; + if (args.is_object() && args.contains("timeout_seconds")) { + const auto& v = args["timeout_seconds"]; + if (v.is_number()) t = v.get(); + else if (v.is_string()) try { t = std::stod(v.get()); } catch (...) {} + } + if (!(t > 0)) return 30; // also catches NaN + return (int)std::clamp(t, 1.0, 600.0); +} + // Expand a leading "~" (and "~/...") to $HOME and expand $VAR / ${VAR} -// environment-variable references. Everything else (including relative -// paths) is left untouched so the process cwd still resolves it. -std::string ExpandPath(const std::string& path) { +// environment-variable references, then resolve a relative path against +// `cwd` (the thread's folder; the process cwd is never used). +std::string ResolvePath(const std::string& path, const std::string& cwd) { if (path.empty()) return path; std::string out; out.reserve(path.size() + 16); @@ -83,15 +96,17 @@ std::string ExpandPath(const std::string& path) { } out += path[i]; } + std::filesystem::path p(out); + if (p.is_relative() && !cwd.empty()) return (std::filesystem::path(cwd) / p).string(); return out; } // ---------------- file/shell tools ---------------- -std::string ToolReadFile(const nlohmann::json& args) { +std::string ToolReadFile(const nlohmann::json& args, const std::string& cwd) { std::string path = GetStringArg(args, "path"); if (path.empty()) return "Error: missing 'path' argument"; - path = ExpandPath(path); + path = ResolvePath(path, cwd); std::ifstream f(path); if (!f) return "Error: cannot open " + path; @@ -111,10 +126,10 @@ std::string ToolReadFile(const nlohmann::json& args) { return s; } -std::string ToolWriteFile(const nlohmann::json& args) { +std::string ToolWriteFile(const nlohmann::json& args, const std::string& cwd) { std::string path = GetStringArg(args, "path"); if (path.empty()) return "Error: missing 'path' argument"; - path = ExpandPath(path); + path = ResolvePath(path, cwd); if (!args.contains("content") || !args["content"].is_string()) return "Error: missing 'content' argument"; std::string content = args["content"].get(); @@ -133,12 +148,12 @@ std::string ToolWriteFile(const nlohmann::json& args) { return "Wrote " + std::to_string(content.size()) + " bytes to " + path; } -std::string ToolEditFile(const nlohmann::json& args) { +std::string ToolEditFile(const nlohmann::json& args, const std::string& cwd) { std::string path = GetStringArg(args, "path"); std::string oldStr = GetStringArg(args, "old_str"); std::string newStr = GetStringArg(args, "new_str"); if (path.empty()) return "Error: missing 'path' argument"; - path = ExpandPath(path); + path = ResolvePath(path, cwd); if (oldStr.empty()) return "Error: 'old_str' must not be empty"; std::ifstream in(path, std::ios::binary); @@ -170,8 +185,9 @@ std::string ToolEditFile(const nlohmann::json& args) { // Shared Windows process runner. timeoutSeconds == 0 disables the timeout // (used by ToolBashDirect for long-running Play-button builds / dev servers). +// The command runs in `cwd` (the process cwd when empty). static std::string RunCommandWin(const std::string& cmd, ToolCancelToken* token, - int timeoutSeconds) { + int timeoutSeconds, const std::string& cwd = {}) { if (cmd.empty()) return "Error: empty command"; if (token && token->cancelled.load()) return "[cancelled]"; @@ -199,7 +215,8 @@ static std::string RunCommandWin(const std::string& cmd, ToolCancelToken* token, cmdBuf.push_back('\0'); if (!CreateProcessA(nullptr, cmdBuf.data(), nullptr, nullptr, TRUE, - CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { + CREATE_NO_WINDOW, nullptr, + cwd.empty() ? nullptr : cwd.c_str(), &si, &pi)) { CloseHandle(hRead); CloseHandle(hWrite); return "Error: CreateProcess failed (" + std::to_string(GetLastError()) + ")"; @@ -266,15 +283,16 @@ static std::string RunCommandWin(const std::string& cmd, ToolCancelToken* token, if (token && token->cancelled.load()) return "[cancelled]"; if (truncated) out += "\n[output truncated]"; if (out.empty()) out = "(no output)"; - if (timedOut) out += "\n[timed out after 30s]"; + if (timedOut) out += "\n[timed out after " + std::to_string(timeoutSeconds) + "s]"; return out; } -std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token) { +std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token, + const std::string& cwd) { std::string cmd = GetStringArg(args, "command"); if (cmd.empty()) return "Error: missing 'command' argument"; - return RunCommandWin(cmd, token, 30); + return RunCommandWin(cmd, token, BashTimeoutSeconds(args), cwd); } #else // !_WIN32 @@ -285,11 +303,19 @@ std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token) { // // Timeout is enforced in the parent (SIGTERM → 500 ms → SIGKILL on the // process group), not via an external `timeout` binary — that GNU utility -// doesn't exist on macOS. -std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token) { +// doesn't exist on macOS. Once bash is reaped after a timeout or Escape, the +// group gets a final SIGKILL: bash can die on SIGTERM while a grandchild that +// ignores it lives on. +// +// The child chdir()s into `cwd` (the thread's folder) before exec, so +// threads in different folders can run bash at the same time. +std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token, + const std::string& cwd) { std::string cmd = GetStringArg(args, "command"); if (cmd.empty()) return "Error: missing 'command' argument"; if (token && token->cancelled.load()) return "[cancelled]"; + const int timeoutSeconds = BashTimeoutSeconds(args); + const char* dir = cwd.empty() ? nullptr : cwd.c_str(); int pfd[2]; if (pipe(pfd) < 0) return "Error: pipe failed"; @@ -308,6 +334,12 @@ std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token) { setpgid(0, 0); dup2(pfd[1], STDOUT_FILENO); dup2(pfd[1], STDERR_FILENO); + // Never run the command somewhere else when the folder is gone. + if (dir && chdir(dir) != 0) { + const char msg[] = "Error: cannot enter the working directory\n"; + (void)!write(STDOUT_FILENO, msg, sizeof msg - 1); + _exit(126); + } // pfd[0]/pfd[1] are O_CLOEXEC so they auto-close on exec. execlp("bash", "bash", "-c", cmd.c_str(), (char*)nullptr); @@ -339,11 +371,11 @@ std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token) { bool pipeEof = false; int status = 0; char buf[4096]; - auto timeoutDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + auto timeoutDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeoutSeconds); auto killDeadline = std::chrono::steady_clock::time_point::max(); for (;;) { - // 30s timeout: same escalation as cancellation (SIGTERM → SIGKILL). + // Timeout: same escalation as cancellation (SIGTERM → SIGKILL). if (!sentTerm && std::chrono::steady_clock::now() >= timeoutDeadline) { timedOut = true; kill(-pid, SIGTERM); @@ -394,6 +426,9 @@ std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token) { } } close(pfd[0]); + // Only after a timeout or Escape: after a normal exit, `nohup … &` + // processes are meant to keep running. + if (sentTerm) kill(-pid, SIGKILL); if (token) token->activePgid.store(0); if (token && token->cancelled.load()) return "[cancelled]"; @@ -402,7 +437,7 @@ std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token) { if (out.empty()) out = "(no output)"; if (timedOut) - out += "\n[timed out after 30s]"; + out += "\n[timed out after " + std::to_string(timeoutSeconds) + "s]"; else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) out += "\n[exit " + std::to_string(WEXITSTATUS(status)) + "]"; else if (WIFSIGNALED(status)) @@ -413,10 +448,10 @@ std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token) { #endif // _WIN32 -std::string ToolListDirectory(const nlohmann::json& args) { +std::string ToolListDirectory(const nlohmann::json& args, const std::string& cwd) { std::string path = GetStringArg(args, "path"); if (path.empty()) path = "."; - path = ExpandPath(path); + path = ResolvePath(path, cwd); namespace fs = std::filesystem; std::error_code ec; @@ -656,14 +691,10 @@ std::string ToolWebSearch(const nlohmann::json& args) { auto r = RequestSync(std::move(spec)); if (!r.ok) return "Error: " + r.error; - // Walk SSE events, find the first `data: {...}` line and extract + // Walk SSE events, find the first `data: {...}` one and extract // result.content[0].text. - std::istringstream stream(r.body); - std::string line; - while (std::getline(stream, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (line.rfind("data: ", 0) != 0) continue; - std::string payload = line.substr(6); + std::string sseBuf; + for (const std::string& payload : TakeSseEvents(sseBuf, r.body, /*streamEnded=*/true)) { try { auto j = nlohmann::json::parse(payload); if (j.contains("result") && j["result"].contains("content") @@ -716,10 +747,10 @@ std::string MimeForImagePath(const std::string& path) { return {}; } -std::string ToolAskVision(const nlohmann::json& args) { +std::string ToolAskVision(const nlohmann::json& args, const std::string& cwd) { std::string path = GetStringArg(args, "path"); if (path.empty()) return "Error: missing 'path' argument"; - path = ExpandPath(path); + path = ResolvePath(path, cwd); std::string question = GetStringArg(args, "question"); if (question.empty()) { @@ -931,7 +962,11 @@ std::string ToolBashDirect(const std::string& cmd, ToolCancelToken* token) { } #endif -nlohmann::json GetToolDefinitions(bool includeGritHistory) { +bool IsWriteTool(const std::string& name) { + return name == "write_file" || name == "edit_file" || name == "run_project"; +} + +nlohmann::json GetToolDefinitions(bool includeGritHistory, bool subagent) { using J = nlohmann::json; J tools = J::array(); @@ -965,14 +1000,20 @@ nlohmann::json GetToolDefinitions(bool includeGritHistory) { tools.push_back(ToolDef( "bash", - "Run a shell command. Captures stdout+stderr, 30 second timeout, runs in the application's cwd." + "Run a shell command. Captures stdout+stderr, runs in the project directory." + " Killed after `timeout_seconds` (default 30, max 600) with the output so far kept." #ifdef _WIN32 " On Windows, uses cmd /c.", #else " Uses bash.", #endif {{"type", "object"}, - {"properties", {{"command", StrParam("Shell command to execute.")}}}, + {"properties", { + {"command", StrParam("Shell command to execute.")}, + {"timeout_seconds", {{"type", "integer"}, + {"description", "Seconds before the command is killed. " + "Default 30, max 600. Raise it for slow builds and tests."}}}, + }}, {"required", {"command"}}})); tools.push_back(ToolDef( @@ -1015,21 +1056,28 @@ nlohmann::json GetToolDefinitions(bool includeGritHistory) { tools.push_back(ToolDef( "grit_history_search", "Search the transcripts of past gritcode conversations across " - "every project the user has worked on, including the current " - "session (the current session is NOT excluded - use it to " - "recover details that context compaction hid from view). " + "every project the user has worked on. The current thread is " + "excluded (its history is already in your context) unless context " + "compaction summarised part of it away; the other " + "threads of this folder are included, and they are where work " + "on this project that you don't see here happened - search them " + "with scope \"folder\" before redoing or guessing at it. " "This is the AUTHORITATIVE source of prior-conversation recall - use " "it first whenever the user references any past work (\"last time\", " "\"once again\", \"we had\", \"how did we\", \"in \"). " "Query budget: typically 1-3 searches per user question. Start with " "ONE broad keyword query; only run more if the first returned strong " - "hits. Returns short snippets (~20-token window) with session_id, " - "turn_index, and full_chars - follow up with grit_history_fetch.", + "hits. Returns short snippets (~20-token window) with the thread " + "title, session_id, turn_index, and full_chars - follow up with " + "grit_history_fetch.", {{"type", "object"}, {"properties", { {"query", StrParam("2-5 keywords. FTS5 MATCH expression (phrases in quotes, AND/OR/NOT, prefix*).")}, {"limit", {{"type", "integer"}, {"description", "Max results (default 5, max 20)."}}}, + {"scope", {{"type", "string"}, {"enum", nlohmann::json::array({"all", "folder"})}, + {"description", "\"all\" (default): every project. " + "\"folder\": only this folder's threads."}}}, }}, {"required", {"query"}}})); @@ -1084,6 +1132,33 @@ nlohmann::json GetToolDefinitions(bool includeGritHistory) { }}, {"required", {"action"}}})); + if (subagent) { + J readOnly = J::array(); + for (auto& t : tools) { + if (!IsWriteTool(t["function"]["name"].get())) + readOnly.push_back(std::move(t)); + } + return readOnly; + } + + tools.push_back(ToolDef( + "task", + "Delegate a self-contained sub-task to a subagent: a separate agent with a " + "fresh context that works in the same folder and returns a concise report. " + "Use it for research that would otherwise fill your context (searching a " + "codebase, reading many files, comparing options), or to run independent " + "investigations at once: several task calls in one response run in " + "parallel. The subagent is read-only: it can read files, list " + "directories, search the web and run shell commands to inspect, but it " + "cannot edit files or start subagents, so make the changes yourself from " + "its report. It sees nothing of this conversation: the prompt must " + "contain everything it needs.", + {{"type", "object"}, + {"properties", { + {"description", StrParam("Short label (3-6 words) shown to the user, e.g. 'Find config loading'.")}, + {"prompt", StrParam("Complete instructions: the goal, relevant paths and context, and what to report back.")}, + }}, + {"required", {"description", "prompt"}}})); return tools; } @@ -1222,7 +1297,8 @@ std::string ToolRunProject(const nlohmann::json& args, const std::string& curren return "Error: unknown action '" + action + "'. Use get, set, forget, or detect."; } -std::string ToolGritHistorySearch(const nlohmann::json& args, MemoryDB* memory) { +std::string ToolGritHistorySearch(const nlohmann::json& args, MemoryDB* memory, + const std::string& cwd, const std::string& threadId) { if (!memory || !memory->IsOpen()) return "Memory index is not available."; std::string query = GetStringArg(args, "query"); if (query.empty()) return "Error: 'query' is required."; @@ -1231,7 +1307,12 @@ std::string ToolGritHistorySearch(const nlohmann::json& args, MemoryDB* memory) limit = args["limit"].get(); if (limit < 1) limit = 1; if (limit > 20) limit = 20; - auto hits = memory->Search(query, limit, std::string{}); + const std::string scope = GetStringArg(args, "scope"); + if (!scope.empty() && scope != "all" && scope != "folder") + return "Error: 'scope' must be \"all\" or \"folder\"."; + if (scope == "folder" && cwd.empty()) + return "Error: scope \"folder\" needs a current folder."; + auto hits = memory->Search(query, limit, threadId, scope == "folder" ? cwd : ""); return MemoryDB::FormatSearchHits(hits); } @@ -1259,16 +1340,18 @@ std::string DispatchTool(const std::string& name, const nlohmann::json& args, ToolCancelToken* token, MemoryDB* memory, const std::string& currentCwd, - bool enableGritHistory) { + bool enableGritHistory, + const std::string& threadId) { if (token && token->cancelled.load()) return "[cancelled]"; try { - if (name == "read_file") return CapOutput(ToolReadFile(args)); - if (name == "write_file") return CapOutput(ToolWriteFile(args)); - if (name == "edit_file") return CapOutput(ToolEditFile(args)); - if (name == "bash") return CapOutput(ToolBash(args, token)); - if (name == "list_directory")return CapOutput(ToolListDirectory(args)); + if (name == "read_file") return CapOutput(ToolReadFile(args, currentCwd)); + if (name == "write_file") return CapOutput(ToolWriteFile(args, currentCwd)); + if (name == "edit_file") return CapOutput(ToolEditFile(args, currentCwd)); + if (name == "bash") return CapOutput(ToolBash(args, token, currentCwd)); + if (name == "list_directory")return CapOutput(ToolListDirectory(args, currentCwd)); if (name == "web_fetch") return ToolWebFetch(args); // already capped - if (name == "ask_vision") return CapOutput(ToolAskVision(args)); + if (name == "ask_vision") return CapOutput(ToolAskVision(args, currentCwd)); + if (name == "web_search") return ToolWebSearch(args); // already capped if (name == "grit_history_search" || name == "grit_history_fetch") { if (!enableGritHistory) @@ -1276,7 +1359,7 @@ std::string DispatchTool(const std::string& name, const nlohmann::json& args, " is disabled by the user (Settings -> " "Enable Grit History tools)."; if (name == "grit_history_search") - return ToolGritHistorySearch(args, memory); + return ToolGritHistorySearch(args, memory, currentCwd, threadId); return ToolGritHistoryFetch(args, memory); } if (name == "run_project") diff --git a/src/tools.h b/src/tools.h index 492cd0a..567242a 100644 --- a/src/tools.h +++ b/src/tools.h @@ -8,8 +8,15 @@ class MemoryDB; // Returns the OpenAI-compatible "tools" array describing all tools the agent // can call. Pass this as the "tools" field of the chat completions request. // When includeGritHistory is false, grit_history_search/fetch are omitted so -// the model cannot see or call them. -nlohmann::json GetToolDefinitions(bool includeGritHistory); +// the model cannot see or call them. Top-level threads also get `task`, which +// the chat frame runs as a child thread (a subagent); subagents get no `task` +// and none of the tools that change files (see IsWriteTool). +nlohmann::json GetToolDefinitions(bool includeGritHistory, bool subagent = false); + +// Tools that change files or project config. Subagents run concurrently with +// their parent and each other in one folder, so they are read-only: these +// tools are neither offered to them nor run for them. +bool IsWriteTool(const std::string& name); // Cancellation handle threaded into the worker that runs a tool batch. // The GUI flips `cancelled` from the GUI thread (Escape key); the worker @@ -28,16 +35,21 @@ struct ToolCancelToken { // // `token` may be null for callers that don't support cancellation; when set, // long-running tools (bash) periodically check it and abort cleanly. -// `memory` + `currentSessionId` back the grit_history_search/fetch tools. +// `memory` backs the grit_history_search/fetch tools. // Pass nullptr for `memory` if the index isn't open (those tools return a // "Memory index unavailable" string in that case). When enableGritHistory is // false, grit_history_search/fetch return a "disabled by the user" error. +// `currentCwd` is the calling thread's folder: relative paths resolve +// against it and bash runs there (the process cwd is never used); it is also +// the folder of grit_history_search's scope "folder". `threadId` (the +// calling thread, or empty) is left out of grit_history_search results. std::string DispatchTool(const std::string& name, const nlohmann::json& args, ToolCancelToken* token, MemoryDB* memory, const std::string& currentCwd, - bool enableGritHistory); + bool enableGritHistory, + const std::string& threadId = {}); // Run a shell command without the 30s timeout. Used by the Play button for // long-running builds. Same cancellation semantics as ToolBash (SIGTERM on