From 57c33c97da392d1ca4b6318a8e137f338cfc99c9 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 19:07:38 +0200 Subject: [PATCH 01/33] docs: lean threads design, plus the orchestrator ideas Co-Authored-By: Claude Opus 5.5 --- docs/design/orchestrator-conversation.md | 108 +++++++++++++++++++++++ docs/design/threads.md | 69 +++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 docs/design/orchestrator-conversation.md create mode 100644 docs/design/threads.md 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..d8385df --- /dev/null +++ b/docs/design/threads.md @@ -0,0 +1,69 @@ +# 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. Every step has a line budget (net added lines in +`src/`, tests separate) that is checked before merging. + +## 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 + +One running turn per folder. Starting a turn while another thread in the same +folder is running queues it (shown as "waiting for "). Real parallel +writing in one folder comes later with worktrees. + +## 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. + +## UI + +The session dropdown lists the current folder's threads (running ones marked) +plus "New thread" and "Open folder…". A left thread pane, the agent map and +the decision tray come later (see `orchestrator-conversation.md`). + +## Steps and budgets + +1. Threads: struct, multi-thread runtime, store, per-thread cwd, write queue, + dropdown. Budget +700 src, +250 tests. +2. Subagents: a DeepSeek `task` tool running a child thread headless, and + Claude Code subagent events shown as child threads. Budget +400 src. +3. Worktree threads (only when the UI uses them). Budget +250 src. From 2d525e8677bdbdd5e3b62fa594dee4faf65bed8d Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 19:08:58 +0200 Subject: [PATCH 02/33] docs: no line budgets; simplest clean solution instead Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index d8385df..957734b 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -5,8 +5,8 @@ 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. Every step has a line budget (net added lines in -`src/`, tests separate) that is checked before merging. +and the standard library well. Aim for the simplest clean solution that reads +naturally; no code golf, and no speculative generality. ## Data @@ -60,10 +60,10 @@ The session dropdown lists the current folder's threads (running ones marked) plus "New thread" and "Open folder…". A left thread pane, the agent map and the decision tray come later (see `orchestrator-conversation.md`). -## Steps and budgets +## Steps 1. Threads: struct, multi-thread runtime, store, per-thread cwd, write queue, - dropdown. Budget +700 src, +250 tests. + dropdown. 2. Subagents: a DeepSeek `task` tool running a child thread headless, and - Claude Code subagent events shown as child threads. Budget +400 src. -3. Worktree threads (only when the UI uses them). Budget +250 src. + Claude Code subagent events shown as child threads. +3. Worktree threads (only when the UI uses them). From b85f31ebab439f86353345b2103a2906891ddb12 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 19:32:47 +0200 Subject: [PATCH 03/33] feat(tools): resolve paths and run bash in the caller's folder Relative paths in the file tools resolve against the cwd DispatchTool is given, and bash chdir()s into it in the forked child (lpCurrentDirectory on Windows), so tools no longer depend on the process cwd. Co-Authored-By: Claude Opus 5.5 --- src/tools.cpp | 67 ++++++++++++++++++++++++++++++++------------------- src/tools.h | 2 ++ 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/tools.cpp b/src/tools.cpp index 879bd8d..3eb708a 100644 --- a/src/tools.cpp +++ b/src/tools.cpp @@ -47,9 +47,9 @@ std::string GetStringArg(const nlohmann::json& args, const char* key) { } // 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 +83,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 +113,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 +135,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 +172,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 +202,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()) + ")"; @@ -271,10 +275,11 @@ static std::string RunCommandWin(const std::string& cmd, ToolCancelToken* token, 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, 30, cwd); } #else // !_WIN32 @@ -286,10 +291,15 @@ 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) { +// +// 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 char* dir = cwd.empty() ? nullptr : cwd.c_str(); int pfd[2]; if (pipe(pfd) < 0) return "Error: pipe failed"; @@ -308,6 +318,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); @@ -413,10 +429,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; @@ -716,10 +732,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()) { @@ -965,7 +981,7 @@ 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, 30 second timeout, runs in the project directory." #ifdef _WIN32 " On Windows, uses cmd /c.", #else @@ -1262,13 +1278,14 @@ std::string DispatchTool(const std::string& name, const nlohmann::json& args, bool enableGritHistory) { 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) diff --git a/src/tools.h b/src/tools.h index 492cd0a..89e13b0 100644 --- a/src/tools.h +++ b/src/tools.h @@ -32,6 +32,8 @@ struct ToolCancelToken { // 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). std::string DispatchTool(const std::string& name, const nlohmann::json& args, ToolCancelToken* token, From 87c4e49d9dfc95ba2a5355c6e83e44844d85ce48 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 19:32:47 +0200 Subject: [PATCH 04/33] =?UTF-8?q?feat:=20threads=20=E2=80=94=20several=20c?= =?UTF-8?q?onversations=20per=20folder,=20running=20at=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatFrame's per-conversation state moves into a plain struct Thread held in a std::deque; the existing functions take Thread&. Async callbacks capture (thread id, gen) and drop stale results. Only the active thread draws to the canvas; switching rebuilds it from history plus the in-flight partial reply. One running turn per folder: a second thread in the same folder waits ("Waiting for to finish…") and starts when the first finishes. The process-wide ChdirToCwd is gone. Storage keeps the 0.5.x layout: a folder's session is its first thread, the index keeps one `sessions` entry per folder and adds `threads` and `lastActiveThread`. Session files not in the index (after 0.5.x rewrote it) are listed again from their headers. Nothing is written at startup. The session dropdown lists the folder's threads (running ones marked), then New thread and Open folder…. The control server gains listThreads, newThread and selectThread; GRIT_MCP_PORT pins its port. Co-Authored-By: Claude Opus 5.5 --- src/chat_frame.cpp | 1573 ++++++++++++++++++++++------------------- src/chat_frame.h | 479 +++++++------ src/mcp_server.cpp | 23 +- src/mcp_server.h | 12 +- src/session_store.cpp | 148 ++-- src/session_store.h | 82 +-- 6 files changed, 1285 insertions(+), 1032 deletions(-) diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 6d859d7..8dd7b90 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -56,17 +56,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; @@ -102,7 +93,7 @@ constexpr int kEditorTextWidth = kFileTreeWidth * 5 / 2; // editor text = 2.5x constexpr int kEditorPaneWidth = kFileTreeWidth + kEditorTextWidth; // whole right pane constexpr int kMainMinClientW = 220; // min chat-pane width with no panels // 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 @@ -624,18 +615,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")) { @@ -772,6 +751,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 +1049,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); @@ -1074,7 +1064,7 @@ ChatFrame::ChatFrame() wxBORDER_NONE); hamburgerBtn_->SetToolTip(wxString::FromUTF8("Toggle session reference")); - sessionLabel_ = new wxStaticText(panel, wxID_ANY, "Session:"); + sessionLabel_ = new wxStaticText(panel, wxID_ANY, "Thread:"); sessionChoice_ = new wxChoice(panel, ID_SESSION); sessionChoice_->SetMinSize(FromDIP(wxSize(160, -1))); modelLabel_ = new wxStaticText(panel, wxID_ANY, "Model:"); @@ -1340,41 +1330,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 +1365,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 +1400,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,12 +1435,15 @@ 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}, + {"waiting", t.waiting}, + {"toolIter", t.toolIter}, + {"historyLen", (int)t.history.size()}, {"pendingQueue", std::move(queue)}, {"continueQueueVisible", continueQueueBtn_->IsShown()}, }; @@ -1477,7 +1454,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 +1469,14 @@ 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.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]() { @@ -1509,8 +1488,8 @@ ChatFrame::ChatFrame() }; cb.cancelRequest = [this]() { CallAfter([this]() { - request_.Cancel(); - claudeProc_.Cancel(); + Active().request.Cancel(); + Active().claudeProc.Cancel(); }); }; cb.getBlocks = [this, guiSync]() { @@ -1534,10 +1513,41 @@ 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& lastUsed, + const Thread* t) { + arr.push_back({ + {"id", id}, {"cwd", cwd}, {"title", title}, + {"lastUsed", lastUsed}, {"loaded", t != nullptr}, + {"running", t && t->streaming && !t->waiting}, + {"waiting", t && t->waiting}, + }); + }; + for (const Thread& t : threads_) add(t.id, t.cwd, t.title, t.lastUsed, &t); + for (const auto& e : store_.List()) + if (!Find(e.id)) add(e.id, e.cwd, e.title, 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}}; }); }; @@ -1647,44 +1657,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 +1686,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) @@ -1728,7 +1721,7 @@ ChatFrame::ChatFrame() }; 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); @@ -1886,10 +1879,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 +1901,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,30 +1917,30 @@ 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); @@ -1995,7 +1988,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 +2021,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 +2041,17 @@ nlohmann::json ChatFrame::BuildConversationSnapshot() const { } void ChatFrame::OnClose(wxCloseEvent& evt) { - // currentToolToken_ is non-null exactly while the tool-dispatch worker is + // currentToolToken is non-null exactly while a tool-dispatch worker is // running. Closing during a tool batch 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.currentToolToken || 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 +2060,7 @@ void ChatFrame::OnClose(wxCloseEvent& evt) { evt.Veto(); return; } - PersistActive(); + Persist(Active()); evt.Skip(); } } @@ -2086,8 +2082,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,16 +2094,25 @@ void ChatFrame::OnCharHook(wxKeyEvent& e) { e.Skip(); } -void ChatFrame::RequestCancel() { +void ChatFrame::RequestCancel(Thread& t) { + // A turn still waiting for its folder hasn't sent anything: drop its + // user message, as the error path does, and end it. + if (t.waiting) { + t.waiting = false; + if (!t.history.empty() && IsUserTurn(t.history.back())) t.history.pop_back(); + RenderErrorBlock(t, "Cancelled."); + FinalizeTurn(t, true); + return; + } // 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(); @@ -2148,7 +2153,8 @@ void ChatFrame::ApplyTheme() { importCanvas_->ApplyTheme(); changeBtn_->SetForegroundColour(importCanvas_->GetPalette().toolAccent); 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); @@ -2165,179 +2171,211 @@ void ChatFrame::ReloadToolbarIcons() { editorBtn_->SetBitmap(LoadThemedSvgIcon("editor.svg", kIconSize, accent)); } +ChatFrame::Thread* ChatFrame::Find(const std::string& id) { + auto it = std::ranges::find(threads_, id, &Thread::id); + return it != threads_.end() ? &*it : nullptr; +} + +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"; +} + void ChatFrame::RefreshSessionChoice() { PERF_SCOPE("RefreshSessionChoice"); - sessionChoice_->Clear(); - sessionCwds_.clear(); - sessionChoice_->Append(wxString::FromUTF8("New Session\xE2\x80\xA6")); - int activeRow = -1; + // 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 { + std::string id; + wxString label; + std::string lastUsed; + }; + std::vector rows; + for (const Thread& t : threads_) { + if (t.cwd != activeCwd_) continue; + wxString label = ThreadLabel(t.id, t.cwd, t.title); + if (t.streaming) label = wxString::FromUTF8("\xE2\x97\x8F ") + label; // "● " + rows.push_back({t.id, label, t.lastUsed}); + } 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 (e.cwd == activeCwd_ && !Find(e.id)) + rows.push_back({e.id, ThreadLabel(e.id, e.cwd, e.title), e.lastUsed}); } - if (activeRow < 1 && sessionCwds_.size() >= 1) activeRow = 1; - if (activeRow >= 1) sessionChoice_->SetSelection(activeRow); - if (activeRow >= 1) { - sessionChoice_->SetToolTip( - wxString::FromUTF8(sessionCwds_[activeRow - 1])); + std::ranges::stable_sort(rows, std::greater{}, &Row::lastUsed); + + sessionChoice_->Clear(); + threadRowIds_.clear(); + for (const Row& r : rows) { + sessionChoice_->Append(r.label); + threadRowIds_.push_back(r.id); } + sessionChoice_->Append("New thread"); + sessionChoice_->Append(wxString::FromUTF8("Open folder\xE2\x80\xA6")); + auto active = std::ranges::find(threadRowIds_, Active().id); + sessionChoice_->SetSelection((int)(active - threadRowIds_.begin())); + sessionChoice_->SetToolTip(wxString::FromUTF8(activeCwd_)); } 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; - } - CreateNewSession(); - return; - } - 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; - } - 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; - } - } - } + const int sel = evt.GetSelection(); + const int threadRows = (int)threadRowIds_.size(); + const std::string id = sel >= 0 && sel < threadRows ? threadRowIds_[sel] + : std::string(); + // Snap back to the active thread; a successful switch reselects. + RefreshSessionChoice(); + if (!id.empty()) SelectThread(id); + else if (sel == threadRows) NewThread(activeCwd_); + else if (sel == threadRows + 1) CreateNewSession(); } 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 first thread, the one older versions knew as the + // folder's session. + const std::string id = SessionStore::IdForCwd(cwd); + if (!Find(id)) OpenThread(id, cwd); + return ShowThread(id); +} + +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)) return true; + threads_.pop_back(); // cancelled; nothing refers to it yet + return false; +} - 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_); +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)) { + RefreshSystemPromptAgents(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 { - RefreshSystemPromptAgents(); + 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(); } - historyCompactBaseCount_ = 0; // fresh compaction gate for the new session - store_.SetLastActiveCwd(activeCwd_); - ChdirToCwd(activeCwd_); + + // The thread we leave keeps running; its canvas indices are now stale. + Active().liveThinkingIdx = -1; + active_ = idx; + Thread& t = Active(); + activeCwd_ = t.cwd; + store_.SetLastActive(t.cwd, t.id); RestoreCanvasMaybeDeferred(); + canvas_->SetThinking(t.streaming); + UpdateQueueUI(); 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, msgs, entry.lastUsed); }); } -void ChatFrame::SeedSystemPrompt() { +void ChatFrame::SeedSystemPrompt(Thread& t) { std::string content = BaseSystemPrompt(); - std::string agents = LoadAgentsInstructions(activeCwd_); + std::string agents = LoadAgentsInstructions(t.cwd); if (!agents.empty()) content += "\n\n" + agents; - history_.push_back({{"role", "system"}, {"content", std::move(content)}}); + t.history.push_back({{"role", "system"}, {"content", std::move(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 +void ChatFrame::RefreshSystemPromptAgents(Thread& t) { + // Rebuild t.history[0]'s content from the static base + current AGENTS.md. + // This re-injects project instructions into an EXISTING thread whose // system message was seeded before AGENTS.md existed (or changed). - if (history_.empty()) return; - auto& m = history_[0]; + 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 agents = LoadAgentsInstructions(t.cwd); + t.lastAgentsContent = agents; std::string content = BaseSystemPrompt(); if (!agents.empty()) content += "\n\n" + agents; m["content"] = std::move(content); @@ -2345,13 +2383,15 @@ void ChatFrame::RefreshSystemPromptAgents() { void ChatFrame::RestoreCanvasFromHistory() { PERF_SCOPE("RestoreCanvasFromHistory"); + Thread& t = Active(); + canvas_->Clear(); canvas_->BeginBatch(); ModelSwitchTracker modelTracker; // 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) { + 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 +2416,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 +2430,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 +2456,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 +2465,7 @@ void ChatFrame::RestoreCanvasFromHistory() { } break; } - RenderToolBlock(name, args, result); + RenderToolBlock(t, name, args, result); } } continue; @@ -2434,9 +2474,24 @@ 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; + if (t.streaming) { + if (t.waiting) RenderWaitingNotice(t); + 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; @@ -2540,12 +2595,12 @@ void ChatFrame::OnModelContextMenu(wxContextMenuEvent&) { } 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 @@ -2599,8 +2654,8 @@ void ChatFrame::OnPlay(wxCommandEvent&) { CallAfter([this, result, cmd]() { if (destroying_.load()) return; currentPlayToken_.reset(); - // Canvas-only display — no history_ modification. - RenderToolBlock("bash", cmd, result); + // Canvas-only display — no history modification. + RenderToolBlock(Active(), "bash", cmd, result); }); }); } else { @@ -2641,7 +2696,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&) { @@ -3023,10 +3079,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) + RefreshSystemPromptAgents(t); + } } void ChatFrame::OnTreeRefresh(wxCommandEvent&) { @@ -3635,8 +3694,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 +3704,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 +3726,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); } @@ -3825,12 +3885,14 @@ void ChatFrame::OnSend(wxCommandEvent&) { text.Trim().Trim(false); if (text.IsEmpty() && pendingImages_.empty()) return; + Thread& t = Active(); + // 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 +3901,19 @@ 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_; + t.overflowRetried = false; + t.turnModelIndex = currentModelIndex_; const ModelRoute route = RouteForIndex(currentModelIndex_, 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 +3924,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 @@ -3881,23 +3943,59 @@ void ChatFrame::StartTurn(const wxString& userText, } userMsg["images"] = std::move(arr); } - history_.push_back(std::move(userMsg)); + t.history.push_back(std::move(userMsg)); + if (t.title.empty()) t.title = DefaultTitle(userText.utf8_string()); + + t.streaming = true; + t.toolIter = 0; + if (Shown(t)) { + canvas_->SetThinking(true); + UpdateQueueUI(); // Send becomes "Add", chip row stays visible if non-empty. + } + + // One running turn per folder: until the other thread there finishes, + // this one waits (StartNextWaiting starts it). + if (RunningIn(t.cwd, t)) { + t.waiting = true; + RenderWaitingNotice(t); + } else { + StartCompletion(t); + } + RefreshSessionChoice(); // marks the thread as running +} + +ChatFrame::Thread* ChatFrame::RunningIn(const std::string& cwd, const Thread& self) { + for (Thread& t : threads_) { + if (&t != &self && t.cwd == cwd && t.streaming && !t.waiting) return &t; + } + return nullptr; +} - streaming_ = true; - canvas_->SetThinking(true); - UpdateQueueUI(); // Send becomes "Add", chip row stays visible if non-empty. - toolIter_ = 0; +void ChatFrame::StartNextWaiting(const std::string& cwd) { + for (Thread& t : threads_) { + if (!t.waiting || t.cwd != cwd) continue; + if (RunningIn(cwd, t)) return; + t.waiting = false; + StartCompletion(t); + return; + } +} - StartCompletion(); +void ChatFrame::RenderWaitingNotice(Thread& t) { + const Thread* other = RunningIn(t.cwd, t); + if (!other) return; + RenderErrorBlock(t, "Waiting for " + ThreadLabel(other->id, other->cwd, other->title) + + wxString::FromUTF8(" to finish\xE2\x80\xA6")); } -void ChatFrame::EnqueueMessage(const wxString& text) { + +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,24 +4024,24 @@ 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; @@ -4017,67 +4115,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(currentModelIndex_, 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 @@ -4150,7 +4245,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 +4303,10 @@ void ChatFrame::DoSendActualRequest() { if (route.needsApiKey) { apiKey = Preferences::GetApiKey(route.provider); if (apiKey.IsEmpty()) { - RenderErrorBlock( + RenderErrorBlock(t, "No API key configured for the selected model. " "Open Settings (gear icon) to add one."); - FinalizeTurn(true); + FinalizeTurn(t, true); return; } } @@ -4229,7 +4324,7 @@ 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; @@ -4281,20 +4376,25 @@ 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)); }); + [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(std::string_view chunk) { +void ChatFrame::OnStreamData(Thread& t, std::string_view chunk) { if (chunk.empty()) return; - sseBuf_.append(chunk.data(), chunk.size()); + t.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); + while ((pos = t.sseBuf.find("\n\n")) != std::string::npos) { + std::string event = t.sseBuf.substr(0, pos); + t.sseBuf.erase(0, pos + 2); std::istringstream stream(event); std::string line; @@ -4309,7 +4409,7 @@ void ChatFrame::OnStreamData(std::string_view chunk) { const auto& choice = j["choices"][0]; if (choice.contains("finish_reason") && choice["finish_reason"].is_string()) - finishReason_ = choice["finish_reason"].get(); + t.finishReason = choice["finish_reason"].get(); if (!choice.contains("delta")) continue; const auto& delta = choice["delta"]; @@ -4317,13 +4417,13 @@ void ChatFrame::OnStreamData(std::string_view chunk) { // 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- + // 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()) { - activeReasoning_ += delta[f].get(); - UpdateLiveThinking(); + t.activeReasoning += delta[f].get(); + UpdateLiveThinking(t); break; } } @@ -4333,9 +4433,9 @@ void ChatFrame::OnStreamData(std::string_view chunk) { // 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)); + EmitPendingThinking(t); + t.activeAssistantText += content; + if (t.mdStream) t.mdStream->Feed(wxString::FromUTF8(content)); } } @@ -4347,12 +4447,12 @@ void ChatFrame::OnStreamData(std::string_view chunk) { // HandleCompletion, but emitting here keeps the order // consistent regardless of when the model interleaves // tool_calls and content. - EmitPendingThinking(); + EmitPendingThinking(t); 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 ((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()) { @@ -4371,7 +4471,7 @@ void ChatFrame::OnStreamData(std::string_view chunk) { } } -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_) { @@ -4381,13 +4481,13 @@ void ChatFrame::OnStreamDone(WebResponse resp) { // 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 +4495,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 +4524,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(currentModelIndex_, t.turnModelIndex, remoteModels_); if (!route.needsApiKey && resp.networkError) { - HandleCompletion(wxString(), /*freeModelStall=*/true); + HandleCompletion(t, wxString(), /*freeModelStall=*/true); return; } @@ -4436,21 +4536,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 +4561,65 @@ 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 + if (freeModelStall) RenderFreeModelStallNotice(t); + else RenderErrorBlock(t, errorIfFailed); + // 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 (!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 +4627,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 +4659,8 @@ 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()); + for (const auto& tc : t.activeToolCalls) { ToolJob job; job.id = tc.id; job.name = tc.name; @@ -4586,18 +4691,19 @@ void ChatFrame::HandleCompletion(const wxString& errorIfFailed, } auto token = std::make_shared(); - currentToolToken_ = token; + t.currentToolToken = token; // 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()); + ++t.gen; + t.toolWorker = std::thread([this, jobs, token, cwd = t.cwd, enableGrit, + id = t.id, gen = t.gen]() { + std::vector results; + results.reserve(jobs->size()); for (auto& job : *jobs) { std::string r; if (token->cancelled.load()) { @@ -4608,19 +4714,18 @@ void ChatFrame::HandleCompletion(const wxString& errorIfFailed, r = DispatchTool(job.name, job.argsParsed, token.get(), &memory_, cwd, enableGrit); } - 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 { + if (Thread* t = Find(id, gen)) OnToolBatchDone(*t, std::move(results)); + }); }); } -void ChatFrame::OnToolBatchDone(wxThreadEvent& e) { - auto results = e.GetPayload>>(); - bool wasCancelled = currentToolToken_ && currentToolToken_->cancelled.load(); - currentToolToken_.reset(); +void ChatFrame::OnToolBatchDone(Thread& t, std::vector results) { + bool wasCancelled = t.currentToolToken && t.currentToolToken->cancelled.load(); + t.currentToolToken.reset(); // 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 @@ -4630,11 +4735,9 @@ void ChatFrame::OnToolBatchDone(wxThreadEvent& e) { return; } - if (!results) return; - - for (auto& r : *results) { - RenderToolBlock(r.name, r.argsJson, r.result); - history_.push_back({ + for (auto& r : results) { + RenderToolBlock(t, r.name, r.argsJson, r.result); + t.history.push_back({ {"role", "tool"}, {"tool_call_id", r.id}, {"name", r.name}, @@ -4647,45 +4750,54 @@ void ChatFrame::OnToolBatchDone(wxThreadEvent& e) { // 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) { // 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.waiting = false; + t.activeAssistantText.clear(); + t.activeReasoning.clear(); + t.activeToolCalls.clear(); + t.mdStream.reset(); + Persist(t); + // Title and running mark changed — refresh the dropdown so the new + // label shows up. RefreshSessionChoice(); + // The folder is free: a thread waiting for it goes before this thread's + // own queue, which would otherwise keep the folder to itself. + StartNextWaiting(t.cwd); + // 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,9 +4805,10 @@ void ChatFrame::FinalizeTurn(bool wasCancelledOrError) { if (input_->IsShown() && IsActive()) input_->SetFocus(); } -void ChatFrame::RenderToolBlock(const std::string& name, +void ChatFrame::RenderToolBlock(Thread& t, const std::string& name, const std::string& argsJson, const std::string& result) { + if (!Shown(t)) return; // Compact the args JSON for display: parse + dump to drop whitespace. std::string displayArgs = argsJson; try { @@ -4730,11 +4843,12 @@ void ChatFrame::RenderToolBlock(const std::string& name, canvas_->AddBlock(std::move(b)); } -void ChatFrame::RenderErrorBlock(const wxString& msg) { - canvas_->AddBlock(NoticeBlock(msg)); +void ChatFrame::RenderErrorBlock(Thread& t, const wxString& msg) { + if (Shown(t)) canvas_->AddBlock(NoticeBlock(msg)); } -void ChatFrame::RenderFreeModelStallNotice() { +void ChatFrame::RenderFreeModelStallNotice(Thread& t) { + if (!Shown(t)) return; wxString text = "The free model stopped responding. Free models can lose their " "connection from time to time. For a reliable experience, set up " @@ -4760,8 +4874,8 @@ void ChatFrame::RenderFreeModelStallNotice() { canvas_->AddBlock(std::move(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 +4884,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 +4966,14 @@ 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); } if (idleQueueMode) { continueQueueBtn_->SetLabel( - FormatU8("Continue ({} queued)", pendingQueue_.size())); + FormatU8("Continue ({} queued)", t.pendingQueue.size())); } chipRow_->Show(hasQueue); @@ -4882,8 +4999,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 +5050,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 +5073,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 +5082,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 +5114,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(currentModelIndex_, 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 +5146,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 +5189,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 +5224,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(currentModelIndex_, t.turnModelIndex, remoteModels_); size_t maxChars = (size_t)(route.contextWindow - 6000) * 4; if (maxChars < 40000) maxChars = 40000; if (headText.size() > maxChars) { @@ -5164,7 +5284,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,20 +5317,25 @@ 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)); }); + [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(std::string_view chunk) { +void ChatFrame::OnSummaryStreamData(Thread& t, std::string_view chunk) { if (chunk.empty()) return; - summarySseBuf_.append(chunk.data(), chunk.size()); + t.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); + while ((pos = t.summarySseBuf.find("\n\n")) != std::string::npos) { + std::string event = t.summarySseBuf.substr(0, pos); + t.summarySseBuf.erase(0, pos + 2); std::istringstream stream(event); std::string line; while (std::getline(stream, line)) { @@ -5225,7 +5350,7 @@ void ChatFrame::OnSummaryStreamData(std::string_view chunk) { if (!choice.contains("delta")) continue; const auto& delta = choice["delta"]; if (delta.contains("content") && delta["content"].is_string()) { - summaryText_ += delta["content"].get(); + t.summaryText += delta["content"].get(); } } catch (...) { // Tolerate keepalives / partial JSON / malformed events. @@ -5234,29 +5359,29 @@ void ChatFrame::OnSummaryStreamData(std::string_view chunk) { } } -void ChatFrame::OnSummaryStreamDone(WebResponse resp) { +void ChatFrame::OnSummaryStreamDone(Thread& t, WebResponse resp) { try { if (quitRequested_) { Close(); return; } // 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 +5390,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 +5422,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 +5445,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 +5457,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 +5465,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 +5487,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 +5509,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 +5534,21 @@ 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); + if (Shown(t)) canvas_->AddBlock(std::move(b)); + if (!t.history.empty() && IsUserTurn(t.history.back())) t.history.pop_back(); + 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,20 +5558,20 @@ 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"); nlohmann::json content = nlohmann::json::array(); if (userMsg.contains("images") && userMsg["images"].is_array()) { @@ -5479,8 +5604,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 +5615,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,7 +5630,7 @@ 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; const std::string promptFile = writeTemp(append); @@ -5517,7 +5642,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 +5651,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 +5685,77 @@ 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 " + t.claudeSessionId = StringField(ev, "session_id"); + LogDebug("claude: session " + t.claudeSessionId + " model " + StringField(ev, "model")); } 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,7 +5767,7 @@ 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; } @@ -5655,36 +5783,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 +5836,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,10 +5877,10 @@ 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(); + t.claudePendingMsg["tool_calls"].push_back({ {"id", toolId}, {"type", "function"}, {"function", {{"name", name}, {"arguments", args}}}, @@ -5762,10 +5897,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 +5909,71 @@ 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({ + RenderToolBlock(t, name, args, result); + 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 +5982,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 +6017,12 @@ void ChatFrame::FailClaudeTurn(const std::string& detail) { } else { msg = "Claude Code error: " + wxString::FromUTF8(shown); } - RenderErrorBlock(msg); + RenderErrorBlock(t, 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); + if (t.history.size() == t.claudeTurnHistoryStart && !t.history.empty() + && IsUserTurn(t.history.back())) + t.history.pop_back(); + FinalizeTurn(t, true); } diff --git a/src/chat_frame.h b/src/chat_frame.h index c7f2f24..752947b 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -22,6 +22,8 @@ #include "streaming_web_request.h" #include "tools.h" #include +#include +#include #include #include #include @@ -52,7 +54,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,17 +77,13 @@ 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 // dropdowns and a gear button on the right. wxChoice* sessionChoice_ = nullptr; wxChoice* modelChoice_ = nullptr; - // "Session:" / "Model:" captions. These, the settings/export/debug + // "Thread:" / "Model:" captions. These, the settings/export/debug // buttons and the model dropdown hide as the chat pane narrows (see // UpdateToolbarFit). wxStaticText* sessionLabel_ = nullptr; @@ -97,17 +96,180 @@ 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; 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 + // 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, including while `waiting`. + bool streaming = false; + // The turn is queued behind another thread running in the same folder + // and starts when that one finishes (see StartNextWaiting). + bool waiting = false; + // 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; + + // 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; + + // ---- 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); + + // On-disk persistence: one session file per thread. The dropdown lists + // the active folder's threads (rows map to ids in threadRowIds_), then + // "New thread" and "Open folder…". SessionStore store_; - std::vector sessionCwds_; + std::vector threadRowIds_; + // 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,71 +284,6 @@ 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 - }; - 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 @@ -206,12 +303,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 +336,21 @@ 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 - // both. Safe to call when nothing is running. Wired to Escape. - void RequestCancel(); + // Cancel whatever the thread has in flight: HTTP stream, tool batch, or + // both, or a turn still waiting for its folder. Safe to call when nothing + // is running. Wired to Escape. + void RequestCancel(Thread& t); void OnSessionChoice(wxCommandEvent&); void OnModelChoice(wxCommandEvent&); void OnModelContextMenu(wxContextMenuEvent&); @@ -336,29 +434,41 @@ 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 thread dropdown: the active folder's threads, most + // recent first (running ones marked), then "New thread" and + // "Open folder…", and select the active thread. 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. + // CreateNewSession pops a directory dialog and switches to that folder's + // first thread. SwitchToCwd opens the first 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, waiting notice). 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(); + // Push the default system prompt into t.history. Called on fresh threads. + void SeedSystemPrompt(Thread& t); + // Re-inject AGENTS.md instructions into t.history[0] for an existing + // (restored or switched-to) thread whose system prompt predates them. + void RefreshSystemPromptAgents(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 +520,61 @@ 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. // 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); + void OnStreamData(Thread& t, std::string_view chunk); + void OnStreamDone(Thread& t, 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 OnToolBatchDone(Thread& t, std::vector results); // 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); + // The thread (other than `self`) running a turn in `cwd`, if any. + Thread* RunningIn(const std::string& cwd, const Thread& self); + // Start the first turn waiting for `cwd`, unless one is running there. + void StartNextWaiting(const std::string& cwd); + 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 +583,61 @@ 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); + 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. + void RenderToolBlock(Thread& t, const std::string& name, const std::string& argsJson, const std::string& result); - void RenderErrorBlock(const wxString& msg); + void RenderErrorBlock(Thread& t, const wxString& msg); + // "Waiting for to finish" while t.waiting. + void RenderWaitingNotice(Thread& t); // Inline notice for when the no-key free model stalls: explains it's the // free tier, recommends DeepSeek, and links to Settings. - void RenderFreeModelStallNotice(); + void RenderFreeModelStallNotice(Thread& t); // 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/mcp_server.cpp b/src/mcp_server.cpp index 38e1281..bcca27c 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; } @@ -220,6 +225,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..99ff178 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,15 @@ 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, waiting}, ...], activeId}. 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. diff --git a/src/session_store.cpp b/src/session_store.cpp index 12f06e0..0ca93df 100644 --- a/src/session_store.cpp +++ b/src/session_store.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace fs = std::filesystem; @@ -70,10 +71,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 +109,39 @@ 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); + AtomicWrite(sessionsDir_ + "/" + e.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}); +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) { +void SessionStore::SetLastActive(const std::string& cwd, const std::string& threadId) { lastActiveCwd_ = cwd; - WriteIndex(); -} - -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; - } - } - if (!found) entries_.push_back({id, cwd, lastUsed}); - SortEntries(); + lastActiveThread_ = threadId; WriteIndex(); } @@ -172,14 +155,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 +180,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..610a87c 100644 --- a/src/session_store.h +++ b/src/session_store.h @@ -4,69 +4,68 @@ #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. // 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); + // 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 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; + // 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: update the in-memory index (lastUsed) + write - // sessions.json. Cheap relative to WriteSessionFile. - void UpdateIndex(const std::string& cwd, const std::string& lastUsed); + // 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. Exposed so callers can compute - // one timestamp and feed it to both WriteSessionFile and UpdateIndex. + // 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); - - // 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 +74,15 @@ class SessionStore { std::vector entries_; std::optional lastActiveCwd_; + std::optional lastActiveThread_; // 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(); }; From 204b1207f9a538d175f90ada99b66662295d5ff9 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 19:32:47 +0200 Subject: [PATCH 05/33] test: threads smoke script; grit.py thread commands Co-Authored-By: Claude Opus 5.5 --- scripts/grit.py | 22 +++++ scripts/smoke/threads.py | 169 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100755 scripts/smoke/threads.py diff --git a/scripts/grit.py b/scripts/grit.py index 3f1ce18..590143c 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 @@ -85,6 +88,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 +151,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/threads.py b/scripts/smoke/threads.py new file mode 100755 index 0000000..2e6476c --- /dev/null +++ b/scripts/smoke/threads.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +""" +Smoke test for threads: drives a hidden gritcode over the control API. + +Usage: + python3 scripts/smoke/threads.py 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), so a run +can fail on model errors (HTTP 503) rather than on gritcode. +""" + +import json +import os +import random +import shutil +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 + +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-threads-") +PORT = 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}"], + env=env, stderr=subprocess.DEVNULL) + pid, g = int(out), Grit(base_port=PORT) + for _ in range(100): + try: + g.status() + return pid, g + except (ConnectionError, 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"] or t["waiting"] 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) +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]["waiting"], "the second thread waits") +check(any("Waiting for" in blk["preview"] for blk in g.blocks()), "waiting notice shown") +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 after the first finished") + +print("-- restart") +stop(pid) +pid, g = launch() +now = threads(g) +check({a, b, b2} <= now.keys(), "threads restored") +check(g.status()["threadId"] == b2, "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) From 5ab7dffd55d11183c612c03377f0140d32241387 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 19:44:16 +0200 Subject: [PATCH 06/33] fix(threads): keep background failures until viewed; open a folder's latest thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn that fails while its thread isn't shown keeps its notice, and the prompt the failure rolled back out of history, on the Thread. Switching to the thread shows the notice and puts the prompt back into the input (unless the user is typing there); until then the dropdown marks the thread with "⚠ ". The no-API-key path now fails like the other errors, rolling back the unsent message. Opening a folder shows its most recently used thread instead of its first one. Co-Authored-By: Claude Opus 5.5 --- src/chat_frame.cpp | 74 ++++++++++++++++++++++++++++++++++------------ src/chat_frame.h | 18 +++++++++-- src/mcp_server.h | 3 +- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 8dd7b90..44e913e 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -1446,6 +1446,7 @@ ChatFrame::ChatFrame() {"historyLen", (int)t.history.size()}, {"pendingQueue", std::move(queue)}, {"continueQueueVisible", continueQueueBtn_->IsShown()}, + {"input", input_->GetValue().utf8_string()}, }; }); }; @@ -1530,6 +1531,7 @@ ChatFrame::ChatFrame() {"lastUsed", lastUsed}, {"loaded", t != nullptr}, {"running", t && t->streaming && !t->waiting}, {"waiting", t && t->waiting}, + {"failed", t && t->failureNotice.has_value()}, }); }; for (const Thread& t : threads_) add(t.id, t.cwd, t.title, t.lastUsed, &t); @@ -2204,6 +2206,8 @@ void ChatFrame::RefreshSessionChoice() { if (t.cwd != activeCwd_) continue; wxString label = ThreadLabel(t.id, t.cwd, t.title); if (t.streaming) label = wxString::FromUTF8("\xE2\x97\x8F ") + label; // "● " + else if (t.failureNotice && !Shown(t)) + label = wxString::FromUTF8("\xE2\x9A\xA0 ") + label; // "⚠ ", until viewed rows.push_back({t.id, label, t.lastUsed}); } for (const auto& e : store_.List()) { @@ -2251,9 +2255,22 @@ void ChatFrame::CreateNewSession() { } bool ChatFrame::SwitchToCwd(const std::string& cwd) { - // A folder opens on its first thread, the one older versions knew as the - // folder's session. - const std::string id = SessionStore::IdForCwd(cwd); + // 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.lastUsed > lastUsed) { + id = t.id; + lastUsed = t.lastUsed; + } + } + for (const auto& e : store_.List()) { + if (e.cwd == cwd && e.lastUsed > lastUsed) { + id = e.id; + lastUsed = e.lastUsed; + } + } if (!Find(id)) OpenThread(id, cwd); return ShowThread(id); } @@ -2325,6 +2342,11 @@ bool ChatFrame::ShowThread(const std::string& 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(); RefreshSessionChoice(); if (otherFolder) { PopulateEditorTree(); @@ -2479,6 +2501,11 @@ void ChatFrame::RestoreCanvasFromHistory() { // 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; + // 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.waiting) RenderWaitingNotice(t); if (t.thinkingEmitted) RenderThinkingBlock(t, wxString::FromUTF8(t.activeReasoning)); @@ -3944,7 +3971,8 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, userMsg["images"] = std::move(arr); } t.history.push_back(std::move(userMsg)); - if (t.title.empty()) t.title = DefaultTitle(userText.utf8_string()); + t.turnPrompt = userText.utf8_string(); + if (t.title.empty()) t.title = DefaultTitle(t.turnPrompt); t.streaming = true; t.toolIter = 0; @@ -4303,10 +4331,10 @@ void ChatFrame::DoSendActualRequest(Thread& t) { if (route.needsApiKey) { apiKey = Preferences::GetApiKey(route.provider); if (apiKey.IsEmpty()) { - RenderErrorBlock(t, + // 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(t, true); return; } } @@ -4574,8 +4602,9 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, EmitPendingThinking(t); if (!errorIfFailed.IsEmpty() || freeModelStall) { - if (freeModelStall) RenderFreeModelStallNotice(t); - else RenderErrorBlock(t, errorIfFailed); + ShowTurnFailure(t, freeModelStall ? FreeModelStallNotice() + : NoticeBlock(errorIfFailed), + /*promptRolledBack=*/true); // 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 @@ -4847,8 +4876,16 @@ void ChatFrame::RenderErrorBlock(Thread& t, const wxString& msg) { if (Shown(t)) canvas_->AddBlock(NoticeBlock(msg)); } -void ChatFrame::RenderFreeModelStallNotice(Thread& t) { - if (!Shown(t)) return; +void ChatFrame::ShowTurnFailure(Thread& t, Block notice, bool promptRolledBack) { + 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 " @@ -4870,8 +4907,7 @@ void ChatFrame::RenderFreeModelStallNotice(Thread& t) { r2.bold = true; r2.link = "gritcode://settings"; b.runs.push_back(r2); - - canvas_->AddBlock(std::move(b)); + return b; } void ChatFrame::RenderThinkingBlock(Thread& t, const wxString& text) { @@ -5534,8 +5570,9 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { b.runs.push_back(link); b.rawText += linkText; b.visibleText += linkText; - if (Shown(t)) canvas_->AddBlock(std::move(b)); - if (!t.history.empty() && IsUserTurn(t.history.back())) t.history.pop_back(); + 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; } @@ -6017,12 +6054,11 @@ void ChatFrame::FailClaudeTurn(Thread& t, const std::string& detail) { } else { msg = "Claude Code error: " + wxString::FromUTF8(shown); } - RenderErrorBlock(t, 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 (t.history.size() == t.claudeTurnHistoryStart && !t.history.empty() - && IsUserTurn(t.history.back())) - t.history.pop_back(); + 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 752947b..4898a95 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -141,6 +142,14 @@ class ChatFrame : public wxFrame { // 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 @@ -439,8 +448,8 @@ class ChatFrame : public wxFrame { // "Open folder…", and select the active thread. void RefreshSessionChoice(); - // CreateNewSession pops a directory dialog and switches to that folder's - // first thread. SwitchToCwd opens the first thread of `cwd` (loading it, + // 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). @@ -626,9 +635,12 @@ class ChatFrame : public wxFrame { void RenderErrorBlock(Thread& t, const wxString& msg); // "Waiting for to finish" while t.waiting. void RenderWaitingNotice(Thread& t); + // 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(Thread& t); + 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). diff --git a/src/mcp_server.h b/src/mcp_server.h index 99ff178..a63fce9 100644 --- a/src/mcp_server.h +++ b/src/mcp_server.h @@ -55,7 +55,8 @@ struct MCPCallbacks { std::function newSession; // Threads. listThreads returns {threads: [{id, cwd, title, lastUsed, - // loaded, running, waiting}, ...], activeId}. newThread adds an empty + // loaded, running, waiting, failed}, ...], 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. From 20a2e8974b58021690768db8c8f319947a960d9e Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 19:44:16 +0200 Subject: [PATCH 07/33] test(threads): smoke checks for background failures and opening a folder Co-Authored-By: Claude Opus 5.5 --- scripts/smoke/threads.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/scripts/smoke/threads.py b/scripts/smoke/threads.py index 2e6476c..97144f9 100755 --- a/scripts/smoke/threads.py +++ b/scripts/smoke/threads.py @@ -125,12 +125,37 @@ def bash(cmd): check(bool(first and second) and float(second) > float(first), "the second ran after the first finished") +print("-- opening a folder") +g.switch_session(A) +check(g.switch_session(B).get("threadId") == b2, "it shows the folder's most recent thread") + +print("-- a turn that fails in the background") +g.select_thread(b) +g.send(bash("sleep 8")) # keeps the folder busy +x = g.new_thread(B)["threadId"] +g.set_model(1) # a DeepSeek model: needs a key, and this HOME has none +g.send("remember this prompt") +check(threads(g)[x]["waiting"], "it waits for the folder") +g.select_thread(b) +g.cancel() # b ends; x starts in the background and fails without a key +end = time.time() + 60 +while time.time() < end and threads(g)[x]["waiting"]: + time.sleep(0.5) +g.set_model(0) +now = threads(g) +check(now[x]["failed"] and not now[x]["running"], "it failed in the background") +g.select_thread(x) +check(any("No API key" in blk["preview"] for blk in g.blocks()), "its notice shows when viewed") +check(g.status()["input"] == "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} <= now.keys(), "threads restored") -check(g.status()["threadId"] == b2, "last active thread restored") +check({a, b, b2, x} <= now.keys(), "threads restored") +check(g.status()["threadId"] == x, "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)") From c47f5bfcc670a5ad081e622caf7f5929c1b79e05 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 22:50:52 +0200 Subject: [PATCH 08/33] docs(threads): list the remaining parallelism steps Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index 957734b..5751619 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -63,7 +63,20 @@ the decision tray come later (see `orchestrator-conversation.md`). ## Steps 1. Threads: struct, multi-thread runtime, store, per-thread cwd, write queue, - dropdown. + dropdown. (done) 2. Subagents: a DeepSeek `task` tool running a child thread headless, and Claude Code subagent events shown as child threads. -3. Worktree threads (only when the UI uses them). +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 (`.grit/brief.md` or generated) + injected into new threads instead of the whole history. +5. Thread pane: an optional left list of the folder's threads and their child + threads with state (running, waiting, failed, 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. +7. Project-scoped history search for the memory tool. + +The orchestrator ideas (`orchestrator-conversation.md`) build on 2 and 5. From 92208115cc47168ed053932c08552aac790f8675 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 22:52:05 +0200 Subject: [PATCH 09/33] fix(tree): "Refresh" no longer saves the open file ID_TREE_REFRESH and ID_EDITOR_SAVE were both wxID_HIGHEST + 21. The handler bound last runs first, so the tree menu's Refresh ran Save: it wrote the open file, or opened Save As when none was open. Give Refresh its own id. Co-Authored-By: Claude Opus 5.5 --- src/chat_frame.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 44e913e..dcd7bb6 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -75,7 +75,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; From f5a67c8286005349ab52f61e0328a2f797e7675f Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 22:54:10 +0200 Subject: [PATCH 10/33] fix(bash): a timed-out command loses its whole process group; timeout_seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a timeout or Escape the group got SIGTERM, but once bash itself died the loop returned before the SIGKILL step, so a grandchild that ignores SIGTERM lived on. SIGKILL the group after bash is reaped (only then: after a normal exit, `nohup … &` processes keep running). The model can pass timeout_seconds (default 30, clamped to 1..600) for slow builds and tests; the description says so. Co-Authored-By: Claude Opus 5.5 --- src/tools.cpp | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/tools.cpp b/src/tools.cpp index 3eb708a..ccca077 100644 --- a/src/tools.cpp +++ b/src/tools.cpp @@ -46,6 +46,19 @@ 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, then resolve a relative path against // `cwd` (the thread's folder; the process cwd is never used). @@ -270,7 +283,7 @@ 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; } @@ -279,7 +292,7 @@ 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, cwd); + return RunCommandWin(cmd, token, BashTimeoutSeconds(args), cwd); } #else // !_WIN32 @@ -290,7 +303,9 @@ 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. +// 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. @@ -299,6 +314,7 @@ std::string ToolBash(const nlohmann::json& args, ToolCancelToken* token, 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]; @@ -355,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); @@ -410,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]"; @@ -418,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)) @@ -981,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 project directory." + "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( From b09303dfa5196ae074d1f20c7ab804e4ff348872 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 22:55:44 +0200 Subject: [PATCH 11/33] fix(run-config): serialize read-modify-write; write atomically Threads run tools at once, and two run_project set calls at the same time each loaded the file, added their entry and saved, so one entry was lost (16 of 320 kept with 16 threads saving at once). Get, Set and Forget now go through one mutex, and Save writes a temp file and renames it, so a reader (also in another gritcode process) never sees a half-written file. Load/Save become private. Co-Authored-By: Claude Opus 5.5 --- src/run_config_store.cpp | 23 +++++++++++++++++++++-- src/run_config_store.h | 14 ++++++++------ 2 files changed, 29 insertions(+), 8 deletions(-) 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); }; From 3753b5818be2e2dacba7e4fb44d529788c36412a Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 23:02:35 +0200 Subject: [PATCH 12/33] =?UTF-8?q?wip(threads):=20subagents=20=E2=80=94=20t?= =?UTF-8?q?ask=20tool=20child=20threads,=20Claude=20Code=20subagent=20thre?= =?UTF-8?q?ads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/chat_frame.cpp | 359 +++++++++++++++++++++++++++++++++++++++------ src/chat_frame.h | 47 +++++- src/tools.cpp | 33 ++++- src/tools.h | 11 +- 4 files changed, 397 insertions(+), 53 deletions(-) diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 44e913e..1cdaa84 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -420,6 +420,35 @@ 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."; + // 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. @@ -488,6 +517,19 @@ Block NoticeBlock(const wxString& msg) { 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]"; @@ -1471,6 +1513,7 @@ ChatFrame::ChatFrame() // the message was accepted (or queued, or rejected as full). return guiSync([this, text]() -> nlohmann::json { 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"}}; @@ -1488,10 +1531,7 @@ ChatFrame::ChatFrame() }); }; cb.cancelRequest = [this]() { - CallAfter([this]() { - Active().request.Cancel(); - Active().claudeProc.Cancel(); - }); + CallAfter([this]() { RequestCancel(Active()); }); }; cb.getBlocks = [this, guiSync]() { return guiSync([this]() { return BuildBlocksSnapshot(); }); @@ -1524,19 +1564,20 @@ ChatFrame::ChatFrame() 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& lastUsed, - const Thread* t) { + const std::string& title, const std::string& parent, + const std::string& lastUsed, const Thread* t) { arr.push_back({ - {"id", id}, {"cwd", cwd}, {"title", title}, + {"id", id}, {"cwd", cwd}, {"title", title}, {"parent", parent}, {"lastUsed", lastUsed}, {"loaded", t != nullptr}, {"running", t && t->streaming && !t->waiting}, {"waiting", t && t->waiting}, {"failed", t && t->failureNotice.has_value()}, }); }; - for (const Thread& t : threads_) add(t.id, t.cwd, t.title, t.lastUsed, &t); + 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.lastUsed, nullptr); + if (!Find(e.id)) add(e.id, e.cwd, e.title, e.parent, e.lastUsed, nullptr); return {{"threads", std::move(arr)}, {"activeId", Active().id}}; }); }; @@ -2043,12 +2084,11 @@ nlohmann::json ChatFrame::BuildConversationSnapshot() const { } void ChatFrame::OnClose(wxCloseEvent& evt) { - // currentToolToken is non-null exactly while a 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 to a dangling `this`. auto busy = [](const Thread& t) { - return t.request.IsActive() || t.currentToolToken || t.claudeProc.IsActive(); + return t.request.IsActive() || t.toolWorkerRunning || t.claudeProc.IsActive(); }; if (std::ranges::any_of(threads_, busy)) { quitRequested_ = true; @@ -2106,6 +2146,13 @@ void ChatFrame::RequestCancel(Thread& t) { FinalizeTurn(t, true); return; } + // 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; + } // Idempotent on a finished request — Cancel just sets an atomic. t.request.Cancel(); // A Claude turn gets SIGINT, so Claude Code ends it cleanly. @@ -2120,6 +2167,9 @@ void ChatFrame::RequestCancel(Thread& t) { 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 // cancelled here. Escape cancels AI-initiated tool calls, not user- @@ -2192,12 +2242,20 @@ wxString ChatFrame::ThreadLabel(const std::string& id, const std::string& 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"); +} + void ChatFrame::RefreshSessionChoice() { PERF_SCOPE("RefreshSessionChoice"); // 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 { std::string id; + std::string parent; wxString label; std::string lastUsed; }; @@ -2208,19 +2266,33 @@ void ChatFrame::RefreshSessionChoice() { if (t.streaming) label = wxString::FromUTF8("\xE2\x97\x8F ") + label; // "● " else if (t.failureNotice && !Shown(t)) label = wxString::FromUTF8("\xE2\x9A\xA0 ") + label; // "⚠ ", until viewed - rows.push_back({t.id, label, t.lastUsed}); + rows.push_back({t.id, t.parent, label, t.lastUsed}); } for (const auto& e : store_.List()) { if (e.cwd == activeCwd_ && !Find(e.id)) - rows.push_back({e.id, ThreadLabel(e.id, e.cwd, e.title), e.lastUsed}); + rows.push_back({e.id, e.parent, ThreadLabel(e.id, e.cwd, e.title), e.lastUsed}); } std::ranges::stable_sort(rows, std::greater{}, &Row::lastUsed); + // Subagent threads go right under their parent, indented. + std::vector ordered; + auto listed = [&rows](const std::string& id) { + return std::ranges::find(rows, id, &Row::id) != rows.end(); + }; + for (const Row& r : rows) { + if (!r.parent.empty() && listed(r.parent)) continue; + ordered.push_back(&r); + for (const Row& c : rows) + if (c.parent == r.id) ordered.push_back(&c); + } + sessionChoice_->Clear(); threadRowIds_.clear(); - for (const Row& r : rows) { - sessionChoice_->Append(r.label); - threadRowIds_.push_back(r.id); + for (const Row* r : ordered) { + const bool child = !r->parent.empty() && listed(r->parent); + sessionChoice_->Append(child ? wxString::FromUTF8(" \xE2\x86\xB3 ") + r->label // "↳ " + : r->label); + threadRowIds_.push_back(r->id); } sessionChoice_->Append("New thread"); sessionChoice_->Append(wxString::FromUTF8("Open folder\xE2\x80\xA6")); @@ -2260,13 +2332,13 @@ bool ChatFrame::SwitchToCwd(const std::string& cwd) { std::string id = SessionStore::IdForCwd(cwd); std::string lastUsed; for (const Thread& t : threads_) { - if (t.cwd == cwd && t.lastUsed > lastUsed) { + 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.lastUsed > lastUsed) { + if (e.cwd == cwd && e.parent.empty() && e.lastUsed > lastUsed) { id = e.id; lastUsed = e.lastUsed; } @@ -2408,6 +2480,9 @@ void ChatFrame::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; // Walk the message list and re-emit blocks. Tool call/result pairs are // stitched back together: an assistant message's tool_calls are matched @@ -2487,7 +2562,7 @@ void ChatFrame::RestoreCanvasFromHistory() { } break; } - RenderToolBlock(t, name, args, result); + RenderToolBlock(t, name, args, result, StringField(tc, "thread")); } } continue; @@ -2736,9 +2811,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()); } } @@ -3913,6 +3991,7 @@ void ChatFrame::OnSend(wxCommandEvent&) { 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 @@ -3935,8 +4014,9 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, std::vector images, const std::string& hiddenInstructions) { t.overflowRetried = false; - t.turnModelIndex = currentModelIndex_; - const ModelRoute route = RouteForIndex(currentModelIndex_, remoteModels_); + t.failure.clear(); + t.turnModelIndex = ModelIndexFor(t); + const ModelRoute route = RouteForIndex(t.turnModelIndex, remoteModels_); const std::string modelId = route.model; RenderModelSwitchNotice(t, modelId); // Render the user prompt block (skipped for image-only messages). @@ -3993,12 +4073,23 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, } ChatFrame::Thread* ChatFrame::RunningIn(const std::string& cwd, const Thread& self) { + // A thread and its subagents count as one owner of the folder (the + // subagents are read-only, see IsWriteTool). + auto family = [](const Thread& t) -> const std::string& { + return t.parent.empty() ? t.id : t.parent; + }; for (Thread& t : threads_) { - if (&t != &self && t.cwd == cwd && t.streaming && !t.waiting) return &t; + if (&t != &self && t.cwd == cwd && t.streaming && !t.waiting + && family(t) != family(self)) + return &t; } return nullptr; } +int ChatFrame::ModelIndexFor(const Thread& t) const { + return t.parent.empty() ? currentModelIndex_ : t.turnModelIndex; +} + void ChatFrame::StartNextWaiting(const std::string& cwd) { for (Thread& t : threads_) { if (!t.waiting || t.cwd != cwd) continue; @@ -4188,7 +4279,7 @@ void ChatFrame::DoSendActualRequest(Thread& t) { t.mdStream = MakeMdStream(t); ModelRoute route = - CompletionRoute(currentModelIndex_, t.turnModelIndex, remoteModels_); + CompletionRoute(ModelIndexFor(t), t.turnModelIndex, remoteModels_); t.requestModel = route.model; // Build an outbound copy of history with the thread's cwd appended to the @@ -4226,6 +4317,10 @@ void ChatFrame::DoSendActualRequest(Thread& t) { 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 @@ -4359,7 +4454,8 @@ void ChatFrame::DoSendActualRequest(Thread& t) { 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) { @@ -4552,7 +4648,7 @@ void ChatFrame::OnStreamDone(Thread& t, 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_, t.turnModelIndex, remoteModels_); + CompletionRoute(ModelIndexFor(t), t.turnModelIndex, remoteModels_); if (!route.needsApiKey && resp.networkError) { HandleCompletion(t, wxString(), /*freeModelStall=*/true); return; @@ -4602,16 +4698,19 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, EmitPendingThinking(t); if (!errorIfFailed.IsEmpty() || freeModelStall) { + // 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=*/true); + /*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 (!t.history.empty()) { + while (rollBack && !t.history.empty()) { std::string role = t.history.back().value("role", std::string{}); if (role == "tool" || role == "assistant") { t.history.pop_back(); @@ -4689,7 +4788,11 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, }; auto jobs = std::make_shared>(); 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; @@ -4716,11 +4819,23 @@ void ChatFrame::HandleCompletion(Thread& t, 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(); 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 @@ -4747,15 +4862,24 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, std::move(job.argsJson), std::move(r)}); } CallAfter([this, id, gen, results = std::move(results)]() mutable { - if (Thread* t = Find(id, gen)) OnToolBatchDone(*t, std::move(results)); + 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(Thread& t, std::vector results) { - bool wasCancelled = t.currentToolToken && t.currentToolToken->cancelled.load(); - t.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. @@ -4763,16 +4887,22 @@ void ChatFrame::OnToolBatchDone(Thread& t, std::vector results) Close(); return; } + if (t.toolWorkerRunning || HasRunningChild(t)) return; + + bool wasCancelled = t.currentToolToken && t.currentToolToken->cancelled.load(); + t.currentToolToken.reset(); - for (auto& r : results) { - RenderToolBlock(t, r.name, r.argsJson, r.result); + 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 @@ -4791,6 +4921,13 @@ void ChatFrame::OnToolBatchDone(Thread& t, std::vector results) } void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { + // 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, "Stopped: the parent's turn ended before this subagent finished."); + } // A cancelled/failed round may never reach EmitPendingThinking; don't // leave its Thinking block live with stale text. if (t.liveThinkingIdx >= 0 && Shown(t)) { @@ -4813,6 +4950,7 @@ void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { // The folder is free: a thread waiting for it goes before this thread's // own queue, which would otherwise keep the folder to itself. StartNextWaiting(t.cwd); + 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 @@ -4834,9 +4972,97 @@ void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { if (input_->IsShown() && IsActive()) input_->SetFocus(); } +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; +} + +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_); + c.history[0]["content"] = StringField(c.history[0], "content") + kSubagentNote; + // 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 { + auto last = std::ranges::find_if(child.history.rbegin(), child.history.rend(), + [](const nlohmann::json& m) { + return StringField(m, "role") == "assistant" + && !StringField(m, "content").empty(); + }); + e->result = last != child.history.rend() + ? Truncated(StringField(*last, "content"), 30'000) + : "The subagent finished without a report."; + } + 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); + FinalizeTurn(c, !failure.empty()); +} + void ChatFrame::RenderToolBlock(Thread& t, const std::string& name, const std::string& argsJson, - const std::string& result) { + const std::string& result, + const std::string& childId) { if (!Shown(t)) return; // Compact the args JSON for display: parse + dump to drop whitespace. std::string displayArgs = argsJson; @@ -4870,6 +5096,8 @@ void ChatFrame::RenderToolBlock(Thread& t, const std::string& name, // 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)); + if (!childId.empty()) + canvas_->AddBlock(ThreadLinkBlock("Subagent thread: ", childId, ThreadLabelOf(childId))); } void ChatFrame::RenderErrorBlock(Thread& t, const wxString& msg) { @@ -4877,6 +5105,7 @@ void ChatFrame::RenderErrorBlock(Thread& t, const wxString& msg) { } void ChatFrame::ShowTurnFailure(Thread& t, Block notice, bool promptRolledBack) { + t.failure = notice.visibleText.utf8_string(); if (Shown(t)) { canvas_->AddBlock(std::move(notice)); return; @@ -5007,6 +5236,10 @@ void ChatFrame::UpdateQueueUI() { 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)", t.pendingQueue.size())); @@ -5153,7 +5386,7 @@ bool ChatFrame::MaybeCompactThenSend(Thread& t) { nlohmann::json view = BuildModelView(t); int viewTokens = EstimatePromptTokens(view); ModelRoute route = - CompletionRoute(currentModelIndex_, t.turnModelIndex, remoteModels_); + CompletionRoute(ModelIndexFor(t), t.turnModelIndex, remoteModels_); int usable = route.contextWindow - kBufferTokens; if (viewTokens < usable) return false; @@ -5260,7 +5493,7 @@ void ChatFrame::RunSummaryThenSend(Thread& t, 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_, t.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) { @@ -5780,10 +6013,19 @@ void ChatFrame::HandleClaudeEvent(Thread& t, const nlohmann::json& ev) { const std::string type = StringField(ev, "type"); if (type == "system") { - if (StringField(ev, "subtype") == "init") { + 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; } @@ -5809,10 +6051,15 @@ void ChatFrame::HandleClaudeEvent(Thread& t, const nlohmann::json& ev) { 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"); @@ -5917,11 +6164,17 @@ void ChatFrame::HandleClaudeEvent(Thread& t, const nlohmann::json& ev) { t.claudeToolUses[toolId] = {name, args}; if (!t.claudePendingMsg.contains("tool_calls")) t.claudePendingMsg["tool_calls"] = nlohmann::json::array(); - t.claudePendingMsg["tool_calls"].push_back({ + 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; @@ -5951,7 +6204,19 @@ void ChatFrame::HandleClaudeEvent(Thread& t, const nlohmann::json& ev) { : std::string("tool"); const std::string args = use != t.claudeToolUses.end() ? use->second.second : std::string(); - RenderToolBlock(t, name, args, result); + 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}, diff --git a/src/chat_frame.h b/src/chat_frame.h index 4898a95..caccc4e 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -106,7 +106,7 @@ class ChatFrame : public wxFrame { }; // Worker-thread tool dispatch result (one entry per tool call). Populated - // off the GUI thread; consumed in OnToolBatchDone. + // off the GUI thread (or by a subagent); consumed in OnToolBatchDone. struct ToolBatchEntry { std::string id; std::string name; @@ -123,6 +123,13 @@ class ChatFrame : public wxFrame { 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). @@ -203,6 +210,11 @@ class ChatFrame : public wxFrame { // 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 @@ -268,6 +280,8 @@ class ChatFrame : public wxFrame { // 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 // the active folder's threads (rows map to ids in threadRowIds_), then @@ -535,7 +549,32 @@ class ChatFrame : public wxFrame { void OnStreamData(Thread& t, std::string_view chunk); void OnStreamDone(Thread& t, WebResponse resp); - void OnToolBatchDone(Thread& t, std::vector results); + // 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 // t.sseBuf (which doubles as raw-body capture under Storage_None). @@ -629,9 +668,11 @@ class ChatFrame : public wxFrame { // 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); + const std::string& result, + const std::string& childId = std::string()); void RenderErrorBlock(Thread& t, const wxString& msg); // "Waiting for to finish" while t.waiting. void RenderWaitingNotice(Thread& t); diff --git a/src/tools.cpp b/src/tools.cpp index 3eb708a..719668f 100644 --- a/src/tools.cpp +++ b/src/tools.cpp @@ -947,7 +947,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(); @@ -1100,6 +1104,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; } diff --git a/src/tools.h b/src/tools.h index 89e13b0..6ac46f3 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 From f3fc281cd92f978a5f1f8c019189206ca126a607 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 23:06:10 +0200 Subject: [PATCH 13/33] fix(sse): parse CRLF streams, an unterminated last event and `data:` without a space The chat and summary streams split events on "\n\n" only, so a CRLF-delimited stream yielded no events at all, a last event without a closing blank line was dropped, and `data:` without a space was ignored. Each chunk also rescanned the whole buffer. One free function, TakeSseEvents, now does it per the SSE spec: lines end in "\n", "\r\n" or "\r", a blank line ends an event, its data lines are joined, one space after the colon is optional. Only the tail of the previous buffer is rescanned. When the stream ends OK, what is left counts as a last event; on errors the buffer still holds the raw body for the error message. The Exa web-search response uses it too. Co-Authored-By: Claude Opus 5.5 --- src/chat_frame.cpp | 178 +++++++++++++++------------------- src/chat_frame.h | 7 +- src/streaming_web_request.cpp | 64 ++++++++++++ src/streaming_web_request.h | 10 ++ src/tools.cpp | 10 +- 5 files changed, 159 insertions(+), 110 deletions(-) diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index dcd7bb6..d26c449 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -4415,86 +4415,73 @@ void ChatFrame::DoSendActualRequest(Thread& t) { }); } -void ChatFrame::OnStreamData(Thread& t, std::string_view chunk) { - if (chunk.empty()) return; - t.sseBuf.append(chunk.data(), chunk.size()); - - size_t pos; - while ((pos = t.sseBuf.find("\n\n")) != std::string::npos) { - std::string event = t.sseBuf.substr(0, pos); - t.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()) - 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; - } +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(t); - t.activeAssistantText += content; - if (t.mdStream) t.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(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(); - } + // ---- 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.). } } } @@ -4506,6 +4493,8 @@ void ChatFrame::OnStreamDone(Thread& t, WebResponse resp) { 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. @@ -5364,33 +5353,21 @@ void ChatFrame::RunSummaryThenSend(Thread& t, int splitIdx) { }); } -void ChatFrame::OnSummaryStreamData(Thread& t, std::string_view chunk) { - if (chunk.empty()) return; - t.summarySseBuf.append(chunk.data(), chunk.size()); - - size_t pos; - while ((pos = t.summarySseBuf.find("\n\n")) != std::string::npos) { - std::string event = t.summarySseBuf.substr(0, pos); - t.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()) { - t.summaryText += delta["content"].get(); - } - } catch (...) { - // Tolerate keepalives / partial JSON / malformed events. +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. } } } @@ -5398,6 +5375,7 @@ void ChatFrame::OnSummaryStreamData(Thread& t, std::string_view chunk) { 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") { diff --git a/src/chat_frame.h b/src/chat_frame.h index 4898a95..7c5604d 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -529,10 +529,11 @@ class ChatFrame : public wxFrame { int imgFile_ = -1; // Streaming HTTP callbacks (delivered on the GUI thread via CallAfter). - // OnStreamData appends to t.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(Thread& t, std::string_view chunk); + void OnStreamData(Thread& t, std::string_view chunk, bool streamEnded = false); void OnStreamDone(Thread& t, WebResponse resp); void OnToolBatchDone(Thread& t, std::vector results); @@ -603,7 +604,7 @@ class ChatFrame : public wxFrame { // 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(Thread& t, std::string_view chunk); + 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 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/tools.cpp b/src/tools.cpp index ccca077..6dff2ea 100644 --- a/src/tools.cpp +++ b/src/tools.cpp @@ -691,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") From 3b9e9d32d66d9fe0696fbf723e374cf705e6cf64 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 23:14:25 +0200 Subject: [PATCH 14/33] wip(threads): subagent smoke test; keep background-cancel notice on children Co-Authored-By: Claude Opus 5.5 --- scripts/smoke/subagents.py | 265 +++++++++++++++++++++++++++++++++++++ src/chat_frame.cpp | 21 ++- 2 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 scripts/smoke/subagents.py diff --git a/scripts/smoke/subagents.py b/scripts/smoke/subagents.py new file mode 100644 index 0000000..6d728c8 --- /dev/null +++ b/scripts/smoke/subagents.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +Smoke test for subagents: drives a hidden gritcode over the control API. + +Usage: + python3 scripts/smoke/subagents.py 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 needs a live model: the free one (Kilo Free) is flaky, so those +results are informational, and DeepSeek is skipped without an API key. +""" + +import json +import os +import random +import shutil +import stat +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 + +LAUNCHER = os.path.expanduser("~/.local/share/grit-dev/run-hidden.sh") +STUBS = os.path.expanduser("~/.local/share/grit-dev/stubs") +BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build/gritcode") +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], + env=env, stderr=subprocess.DEVNULL) + pid, g = int(out), Grit(base_port=PORT) + for _ in range(100): + try: + g.status() + return pid, g + except (ConnectionError, 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"] or t["waiting"] 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()) + + +# ---- 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") + +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") +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 1cdaa84..29b6c1b 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -4926,7 +4926,7 @@ void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { // for its `task` subagents, so those are done by now. for (Thread& c : threads_) { if (c.parent == t.id && c.streaming) - FinishClaudeSubagent(c, "Stopped: the parent's turn ended before this subagent finished."); + 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. @@ -4942,6 +4942,10 @@ void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { 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. @@ -5054,8 +5058,21 @@ void ChatFrame::FinishClaudeSubagent(Thread& c, const std::string& failure) { c.mdStream.reset(); EmitPendingThinking(c); FlushClaudePendingMessage(c); - if (!failure.empty()) + 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()); } From 4707f503adefc5d14348921ea66aabfef4b76589 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 23:40:24 +0200 Subject: [PATCH 15/33] =?UTF-8?q?feat(threads):=20subagents=20=E2=80=94=20?= =?UTF-8?q?task=20tool=20and=20Claude=20Code=20subagent=20threads=20(step?= =?UTF-8?q?=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek/Kilo: top-level threads get a task tool; each call runs a read-only child thread with a fresh context, concurrently with the rest of the batch, and its final message (or failure) becomes the call's result in call order. Claude Code: Agent/Task calls get a child thread that assembles the subagent's events with the top-level code. Children are view-only, listed under their parent and linked from its canvas; a thread and its subagents count as one owner of the folder. Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 37 ++++++++++++++++++++++++++++++++++--- src/chat_frame.cpp | 13 +++++-------- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index 5751619..9f83d27 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -45,6 +45,37 @@ One running turn per folder. Starting a turn while another thread in the same folder is running queues it (shown as "waiting for "). Real parallel writing in one folder comes later with worktrees. +A thread and its subagents count as one owner of the folder, so a subagent +never waits for its own parent. 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 dropdown 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`, @@ -56,8 +87,8 @@ working: old versions see every folder's first thread with full history. ## UI -The session dropdown lists the current folder's threads (running ones marked) -plus "New thread" and "Open folder…". A left thread pane, the agent map and +The session dropdown lists the current folder's threads (running ones marked, +subagents indented under their parent) plus "New thread" and "Open folder…". A left thread pane, the agent map and the decision tray come later (see `orchestrator-conversation.md`). ## Steps @@ -65,7 +96,7 @@ the decision tray come later (see `orchestrator-conversation.md`). 1. Threads: struct, multi-thread runtime, store, per-thread cwd, write queue, dropdown. (done) 2. Subagents: a DeepSeek `task` tool running a child thread headless, and - Claude Code subagent events shown as child threads. + 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 diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 29b6c1b..5a66fba 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -5025,14 +5025,11 @@ void ChatFrame::ReportToParent(Thread& child, bool failed) { ? "Error: the subagent was cancelled." : "Error: the subagent failed: " + child.failure; } else { - auto last = std::ranges::find_if(child.history.rbegin(), child.history.rend(), - [](const nlohmann::json& m) { - return StringField(m, "role") == "assistant" - && !StringField(m, "content").empty(); - }); - e->result = last != child.history.rend() - ? Truncated(StringField(*last, "content"), 30'000) - : "The subagent finished without a report."; + 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); } From c01e914778ef19311214f712b939879fe11a44c3 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 23:52:59 +0200 Subject: [PATCH 16/33] feat(threads): project brief in every thread's system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new thread starts without the history of the folder's other threads. A short per-folder brief (what the project is, its layout and conventions, the current work) and a list of the folder's other titled threads now go into every thread's system prompt, next to AGENTS.md, and into Claude turns' appended prompt. The brief lives in the app data dir as briefs/.md, keyed like sessions, so nothing is written into the user's repo. "Project brief…" in the thread dropdown opens a dialog to edit it; "Generate" streams a draft from DeepSeek Flash (or the free model without a key), with failures shown in the dialog. A new thread in a folder with other threads and no brief shows a one-line hint linking to the dialog; nothing is generated unasked. scripts/smoke/brief.py checks the injection; threads.py takes GRIT_MCP_PORT. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + docs/design/threads.md | 32 +++++- scripts/smoke/brief.py | 112 +++++++++++++++++++++ scripts/smoke/threads.py | 2 +- src/chat_frame.cpp | 144 +++++++++++++++++++++++---- src/chat_frame.h | 27 ++++- src/project_brief.cpp | 207 +++++++++++++++++++++++++++++++++++++++ src/project_brief.h | 32 ++++++ src/session_store.cpp | 31 +++++- src/session_store.h | 8 ++ 10 files changed, 566 insertions(+), 30 deletions(-) create mode 100644 scripts/smoke/brief.py create mode 100644 src/project_brief.cpp create mode 100644 src/project_brief.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a1b470..44ac320 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -252,6 +252,7 @@ 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 ) diff --git a/docs/design/threads.md b/docs/design/threads.md index 5751619..f2b50bf 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -54,10 +54,35 @@ 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, when AGENTS.md changes and when the +brief is saved; 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. + +"Project brief…" in the thread dropdown opens a dialog to edit it. "Generate" +asks DeepSeek Flash (cheap and fast; the free model without a key; Claude's +CLI doesn't fit one request) to write it from the top-level entries, README, +AGENTS.md and thread titles. The text streams into the box; failures show in +red and restore the previous text. Nothing is generated unasked: a new thread +in a folder with other threads and no brief only shows a one-line hint +linking to the dialog. + ## UI The session dropdown lists the current folder's threads (running ones marked) -plus "New thread" and "Open folder…". A left thread pane, the agent map and +plus "New thread", "Project brief…" and "Open folder…". A left thread pane, the agent map and the decision tray come later (see `orchestrator-conversation.md`). ## Steps @@ -71,8 +96,9 @@ the decision tray come later (see `orchestrator-conversation.md`). 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 (`.grit/brief.md` or generated) - injected into new threads instead of the whole history. +4. Project brief: a short per-folder brief (written or generated) and a + list of the folder's other threads in every thread's system prompt. + (done) 5. Thread pane: an optional left list of the folder's threads and their child threads with state (running, waiting, failed, done). 6. Worktree threads: a thread can run in its own git worktree, so several diff --git a/scripts/smoke/brief.py b/scripts/smoke/brief.py new file mode 100644 index 0000000..ab2293e --- /dev/null +++ b/scripts/smoke/brief.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Smoke test for the project brief: 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). No model turns: the folder's threads are seeded +into the index before launch. +""" + +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 + +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 hint_shown(g): + return any("A project brief tells" in 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) +with open(os.path.join(P, "AGENTS.md"), "w") as f: + f.write("agents-marker: run make test\n") +# 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) + +env = dict(os.environ, GRIT_TEST_DATA=DATA) +pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}"], + 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(base_port=PORT) + threads = g.list_threads()["threads"] + first = next(t["id"] for t in threads if t["cwd"] == P and t["id"] != "00000000000000aa") + + 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") + fresh = g.new_thread(P)["threadId"] + check(hint_shown(g), "a new thread offers a brief") + sp = system_prompt(g, fresh) + check("Fix the SSE parser" in sp and "Add dark mode" in sp, "a new thread lists both threads") + + print("-- with a brief") + os.makedirs(os.path.join(DATA, "gritcode", "briefs"), exist_ok=True) + with open(os.path.join(DATA, "gritcode", "briefs", first + ".md"), "w") as f: + f.write("brief-marker: a C++ chat app.") + fresh = g.new_thread(P)["threadId"] + check(not hint_shown(g), "no hint once the folder has a brief") + sp = system_prompt(g, fresh) + check("brief-marker: a C++ chat app." in sp, "the brief is injected") + check("agents-marker" in sp, "together with AGENTS.md") + + print("-- another folder") + q = g.switch_session(Q)["threadId"] + sp = system_prompt(g, q) + check("brief-marker" not in sp and "Other threads" not in sp, "nothing from P") + g.new_thread(Q) + check(not hint_shown(g), "no hint without other threads") +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 index 97144f9..a2ab989 100755 --- a/scripts/smoke/threads.py +++ b/scripts/smoke/threads.py @@ -25,7 +25,7 @@ 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-threads-") -PORT = random.randint(18800, 18899) +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 diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index d26c449..ac42930 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" @@ -420,6 +421,20 @@ ModelRoute CompletionRoute(int currentIdx, int turnIdx, return route; } +// Model that writes a project brief: DeepSeek Flash when there is a key (it +// is cheap and fast), otherwise the free model. Claude runs through its CLI, +// which doesn't fit a single request. +ModelRoute BriefRoute(const std::vector& remoteModels) { + if (!Preferences::HasApiKey(Preferences::Provider::DeepSeek)) + return RouteForIndex(kModelKiloFree, remoteModels); + const std::vector& ids = DeepseekModels(remoteModels); + auto flash = std::ranges::find_if(ids, [](const std::string& id) { + return id.find("flash") != std::string::npos; + }); + return RouteForIndex(flash == ids.end() ? 1 : 1 + (int)(flash - ids.begin()), + remoteModels); +} + // 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. @@ -488,6 +503,23 @@ Block NoticeBlock(const wxString& msg) { return b; } +// Shown in a new thread of a folder that has other threads but no brief. +Block BriefHintNotice() { + const wxString text = + "This thread starts without the history of the folder's other " + "threads. A project brief tells every thread what the project is " + "about. "; + const wxString linkText = wxString::FromUTF8("Write or generate one\xE2\x80\xA6"); + Block b = NoticeBlock(text); + b.rawText = b.visibleText = text + linkText; + InlineRun link; + link.text = linkText; + link.bold = true; + link.link = "gritcode://brief"; + b.runs.push_back(link); + 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]"; @@ -698,9 +730,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 @@ -2223,6 +2254,7 @@ void ChatFrame::RefreshSessionChoice() { threadRowIds_.push_back(r.id); } sessionChoice_->Append("New thread"); + sessionChoice_->Append(wxString::FromUTF8("Project brief\xE2\x80\xA6")); sessionChoice_->Append(wxString::FromUTF8("Open folder\xE2\x80\xA6")); auto active = std::ranges::find(threadRowIds_, Active().id); sessionChoice_->SetSelection((int)(active - threadRowIds_.begin())); @@ -2238,7 +2270,8 @@ void ChatFrame::OnSessionChoice(wxCommandEvent& evt) { RefreshSessionChoice(); if (!id.empty()) SelectThread(id); else if (sel == threadRows) NewThread(activeCwd_); - else if (sel == threadRows + 1) CreateNewSession(); + else if (sel == threadRows + 1) EditProjectBrief(); + else if (sel == threadRows + 2) CreateNewSession(); } void ChatFrame::CreateNewSession() { @@ -2279,7 +2312,11 @@ 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)) return true; + if (ShowThread(t.id)) { + if (store_.LoadBrief(cwd).empty() && !FolderThreads(cwd).empty()) + canvas_->AddBlock(BriefHintNotice()); + return true; + } threads_.pop_back(); // cancelled; nothing refers to it yet return false; } @@ -2308,7 +2345,7 @@ ChatFrame::Thread& ChatFrame::OpenThread(const std::string& id, t.lastUsed = e->lastUsed; } if (store_.Load(id, t.history)) { - RefreshSystemPromptAgents(t); + RefreshSystemPrompt(t); // Sessions from before threads have no title yet. if (t.title.empty()) { auto first = std::ranges::find_if(t.history, IsUserTurn); @@ -2382,25 +2419,88 @@ void ChatFrame::Persist(Thread& t) { }); } -void ChatFrame::SeedSystemPrompt(Thread& t) { +std::string ChatFrame::SystemPrompt(Thread& t) { + t.lastAgentsContent = LoadAgentsInstructions(t.cwd); std::string content = BaseSystemPrompt(); - std::string agents = LoadAgentsInstructions(t.cwd); - if (!agents.empty()) content += "\n\n" + agents; - t.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; + return content; } -void ChatFrame::RefreshSystemPromptAgents(Thread& t) { - // Rebuild t.history[0]'s content from the static base + current AGENTS.md. - // This re-injects project instructions into an EXISTING thread whose - // system message was seeded before AGENTS.md existed (or changed). +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(t.cwd); - t.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 + " (kept by the user, 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." + list; + } + return out; +} + +void ChatFrame::EditProjectBrief() { + const std::string cwd = activeCwd_; + std::vector titles; + for (const FolderThread& f : FolderThreads(cwd)) titles.push_back(f.title); + const ModelRoute route = BriefRoute(remoteModels_); + const BriefModel model{ + .url = route.url, + .model = route.model, + .apiKey = route.needsApiKey + ? Preferences::GetApiKey(route.provider).utf8_string() + : std::string(), + .deepseek = route.needsApiKey, + .label = ModelLabelForId(route.model), + }; + const auto brief = ShowBriefDialog(this, DisplayPath(cwd), store_.LoadBrief(cwd), model, + BriefMaterial(cwd, LoadAgentsInstructions(cwd), titles)); + if (!brief) return; + if (!store_.SaveBrief(cwd, *brief)) { + wxMessageBox("The project brief could not be saved.", "Project brief", + wxOK | wxICON_ERROR, this); + return; + } + for (Thread& t : threads_) + if (t.cwd == cwd) RefreshSystemPrompt(t); } void ChatFrame::RestoreCanvasFromHistory() { @@ -2739,6 +2839,8 @@ void ChatFrame::OnCanvasLink(wxCommandEvent& e) { if (e.GetString() == "gritcode://settings") { wxCommandEvent ev; OnSettings(ev); + } else if (e.GetString() == "gritcode://brief") { + EditProjectBrief(); } } @@ -3111,7 +3213,7 @@ void ChatFrame::OnTreeRefreshTimer(wxTimerEvent&) { std::string agents = LoadAgentsInstructions(activeCwd_); for (Thread& t : threads_) { if (t.cwd == activeCwd_ && agents != t.lastAgentsContent) - RefreshSystemPromptAgents(t); + RefreshSystemPrompt(t); } } @@ -5647,6 +5749,8 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { "pass cwd = the project root), 'forget' removes it."; 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 diff --git a/src/chat_frame.h b/src/chat_frame.h index 7c5604d..2e99029 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -473,11 +473,30 @@ class ChatFrame : public wxFrame { // followed by the in-flight turn (partial text, waiting notice). void RestoreCanvasFromHistory(); - // Push the default system prompt into t.history. Called on fresh threads. + // 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); - // Re-inject AGENTS.md instructions into t.history[0] for an existing - // (restored or switched-to) thread whose system prompt predates them. - void RefreshSystemPromptAgents(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 and a short list of its other threads, so a + // new thread knows what the project is and that parallel work exists. + // Empty when the folder has neither. + std::string FolderContext(const Thread& t) const; + // "Project brief…": edit (or generate) the active folder's brief, then + // refresh the system prompts of its open threads. + void EditProjectBrief(); // Re-render SVG icons with the current system foreground color. Called // once at construction and again on EVT_SYS_COLOUR_CHANGED so the icons diff --git a/src/project_brief.cpp b/src/project_brief.cpp new file mode 100644 index 0000000..a5c29ad --- /dev/null +++ b/src/project_brief.cpp @@ -0,0 +1,207 @@ +#include "project_brief.h" +#include "streaming_web_request.h" +#include +#include +#include +#include +#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; +} + +std::optional ShowBriefDialog(wxWindow* parent, const wxString& folder, + const std::string& brief, + const BriefModel& model, + const std::string& material) { + constexpr int kWrap = 600; + wxDialog dlg(parent, wxID_ANY, "Project brief", wxDefaultPosition, + wxSize(kWrap + 40, 520), wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER); + auto* sizer = new wxBoxSizer(wxVERTICAL); + auto* intro = new wxStaticText(&dlg, wxID_ANY, + "What the project in " + folder + " is about: what it is, its layout " + "and conventions, and the current work. Every thread in this folder " + "gets it in its system prompt, so a new thread can help without the " + "history of the others. Leave it empty for no brief."); + intro->Wrap(kWrap); + sizer->Add(intro, 0, wxALL, 12); + auto* text = new wxTextCtrl(&dlg, wxID_ANY, wxString::FromUTF8(brief), + wxDefaultPosition, wxDefaultSize, wxTE_MULTILINE); + sizer->Add(text, 1, wxEXPAND | wxLEFT | wxRIGHT, 12); + auto* status = new wxStaticText(&dlg, wxID_ANY, wxString()); + sizer->Add(status, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); + auto* buttons = new wxBoxSizer(wxHORIZONTAL); + auto* generate = new wxButton(&dlg, wxID_ANY, "Generate"); + generate->SetToolTip("Ask " + model.label + " to write the brief from the " + "README, AGENTS.md, the top-level files and the " + "thread titles"); + auto* save = new wxButton(&dlg, wxID_OK, "Save"); + buttons->Add(generate); + buttons->AddStretchSpacer(); + buttons->Add(new wxButton(&dlg, wxID_CANCEL, "Cancel"), 0, wxRIGHT, 8); + buttons->Add(save); + sizer->Add(buttons, 0, wxEXPAND | wxALL, 12); + dlg.SetSizer(sizer); + + // Declared after the dialog, so it is cancelled and joined first; the + // dialog then drops any of its callbacks still queued. + StreamingWebRequest request; + std::string sseBuf; + wxString before; // the text Generate replaced, back if it fails + + auto setStatus = [&](const wxString& msg, bool failed) { + status->SetLabel(msg); + status->SetForegroundColour(failed ? *wxRED : wxNullColour); + status->Wrap(kWrap); + dlg.Layout(); + }; + auto append = [&](std::string_view chunk, bool ended) { + for (const std::string& payload : TakeSseEvents(sseBuf, chunk, ended)) + text->AppendText(wxString::FromUTF8(DeltaText(payload))); + }; + auto done = [&](WebResponse resp) { + if (resp.ok) append({}, true); + generate->Enable(); + save->Enable(); + std::string error; + if (!resp.ok) error = resp.error + (sseBuf.empty() ? "" : ": " + ErrorDetail(sseBuf)); + else if (Trimmed(text->GetValue().utf8_string()).empty()) + error = "the model returned no text"; + if (error.empty()) { + setStatus("Written by " + model.label + ". Check it and edit it if " + "needed, then Save.", false); + return; + } + text->SetValue(before); + setStatus(wxString::FromUTF8("\xE2\x9A\xA0 Generation failed: " + error + + ". The previous text is back."), true); + }; + generate->Bind(wxEVT_BUTTON, [&](wxCommandEvent&) { + before = text->GetValue(); + text->Clear(); + sseBuf.clear(); + generate->Disable(); + save->Disable(); + setStatus("Generating with " + model.label + wxString::FromUTF8("\xE2\x80\xA6"), false); + request = StreamingWebRequest(&dlg, BriefRequest(model, material), + [&](std::string_view chunk) { append(chunk, false); }, + done); + }); + + if (dlg.ShowModal() != wxID_OK) return std::nullopt; + return Trimmed(text->GetValue().utf8_string()); +} diff --git a/src/project_brief.h b/src/project_brief.h new file mode 100644 index 0000000..43b5ef8 --- /dev/null +++ b/src/project_brief.h @@ -0,0 +1,32 @@ +#pragma once +#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. Every thread in the folder +// gets it in its system prompt (SessionStore keeps it). + +// The model "Generate" asks: an OpenAI-style chat completions endpoint. +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 + wxString label; // shown while generating +}; + +// 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); + +// Modal "Project brief" dialog for `folder`: edit the brief, or let "Generate" +// write it from `material`. Returns the text to save (trimmed, empty for no +// brief) when the user pressed Save, std::nullopt when cancelled. +std::optional ShowBriefDialog(wxWindow* parent, const wxString& folder, + const std::string& brief, + const BriefModel& model, + const std::string& material); diff --git a/src/session_store.cpp b/src/session_store.cpp index 0ca93df..257c722 100644 --- a/src/session_store.cpp +++ b/src/session_store.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace fs = std::filesystem; @@ -34,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 @@ -139,6 +145,27 @@ void SessionStore::UpdateIndex(const Entry& e) { WriteIndex(); } +std::string SessionStore::BriefPath(const std::string& cwd) const { + return root_ + "/briefs/" + IdForCwd(cwd) + ".md"; +} + +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; + } + 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; diff --git a/src/session_store.h b/src/session_store.h index 610a87c..02d7d8d 100644 --- a/src/session_store.h +++ b/src/session_store.h @@ -11,6 +11,8 @@ // ~/.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 { @@ -63,6 +65,11 @@ class SessionStore { // ISO-8601 "now" string used as lastUsed. static std::string NowIso(); + // 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; + // 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); @@ -76,6 +83,7 @@ class SessionStore { 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_ + the last-active pointers to indexPath_ atomically. From b0d62a080a0ea77ca3e787f6027404c2e57a9a58 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 19 Oct 2026 23:59:07 +0200 Subject: [PATCH 17/33] test(smoke): fake free model for the threads and subagents smoke tests The smoke tests ran their turns on the shared Kilo free tier, which stalls or drops one of two concurrent requests, so the concurrency checks failed at random and the `task` tool had no repeatable test. GRIT_FREE_MODEL_URL (a test/dev hook, read in RouteForIndex) overrides the free model's endpoint. scripts/smoke/fake_model.py is a stdlib, threaded, 127.0.0.1-only OpenAI-compatible SSE server scripted by the prompt: bash or arbitrary tool calls (task, write_file), a text reply after tool results, HTTP 500, and CRLF / unterminated-last-event framing on request, with a 1.1 s delay per reply. threads.py and subagents.py start it in-process by default (--live keeps the real Kilo behaviour). threads.py checks the step-3 SSE fixes end to end; subagents.py's task-tool section is now real checks: two calls in a batch run at once and report in call order, a failing child gives the parent an error result, children can't write_file, and cancelling the parent stops the child and kills its bash. Co-Authored-By: Claude Opus 5.5 --- scripts/smoke/fake_model.py | 118 ++++++++++++++++++++++++++++++++++++ scripts/smoke/subagents.py | 117 +++++++++++++++++++++++++++-------- scripts/smoke/threads.py | 29 +++++++-- src/chat_frame.cpp | 12 +++- 4 files changed, 244 insertions(+), 32 deletions(-) create mode 100755 scripts/smoke/fake_model.py diff --git a/scripts/smoke/fake_model.py b/scripts/smoke/fake_model.py new file mode 100755 index 0000000..c05724c --- /dev/null +++ b/scripts/smoke/fake_model.py @@ -0,0 +1,118 @@ +#!/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: | ..."; +- 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 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 + + def do_POST(self): + request = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + messages = request["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/subagents.py b/scripts/smoke/subagents.py index 6d728c8..c3bb217 100644 --- a/scripts/smoke/subagents.py +++ b/scripts/smoke/subagents.py @@ -3,13 +3,14 @@ Smoke test for subagents: drives a hidden gritcode over the control API. Usage: - python3 scripts/smoke/subagents.py build/gritcode + 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 needs a live model: the free one (Kilo Free) is flaky, so those -results are informational, and DeepSeek is skipped without an API key. +`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 @@ -22,12 +23,17 @@ import tempfile import time -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +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") -BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build/gritcode") +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) @@ -51,7 +57,7 @@ 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], + out = subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", path, *MODEL_ENV], env=env, stderr=subprocess.DEVNULL) pid, g = int(out), Grit(base_port=PORT) for _ in range(100): @@ -117,6 +123,24 @@ 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): @@ -237,26 +261,71 @@ def scenario(events): g.select_thread(top) check("Subagent thread: Count files" in blocks(g), "the restored parent links to the child") -print("-- task tool (live, informational)") -models = [(0, "Kilo Free")] -if g.preferences().get("hasDeepseekKey"): - models.append((2, "DeepSeek")) +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("skip DeepSeek subagents (no API key)") -for row, name in models: - g.set_model(row) + 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 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") + 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") diff --git a/scripts/smoke/threads.py b/scripts/smoke/threads.py index 97144f9..a8f2a3f 100755 --- a/scripts/smoke/threads.py +++ b/scripts/smoke/threads.py @@ -3,11 +3,12 @@ Smoke test for threads: drives a hidden gritcode over the control API. Usage: - python3 scripts/smoke/threads.py build/gritcode + 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), so a run -can fail on model errors (HTTP 503) rather than on gritcode. +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 @@ -19,11 +20,16 @@ import tempfile import time -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +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") +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 = random.randint(18800, 18899) INDEX = os.path.join(DATA, "gritcode", "sessions.json") @@ -33,7 +39,7 @@ def launch(): env = dict(os.environ, GRIT_TEST_DATA=DATA) - out = subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}"], + out = subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", *MODEL_ENV], env=env, stderr=subprocess.DEVNULL) pid, g = int(out), Grit(base_port=PORT) for _ in range(100): @@ -129,6 +135,17 @@ def bash(cmd): g.switch_session(A) check(g.switch_session(B).get("threadId") == b2, "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") + print("-- a turn that fails in the background") g.select_thread(b) g.send(bash("sleep 8")) # keeps the folder busy diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index f54afb7..1210a78 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -354,6 +354,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 @@ -361,7 +369,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}; } @@ -385,7 +393,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}; } From 08e18653aed8f1374af4c8ad7c646719a34dbad8 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 20 Oct 2026 00:09:24 +0200 Subject: [PATCH 18/33] feat(threads): project-scoped history search grit_history_search leaves out the calling thread, takes scope "folder" and shows each hit's thread title (marking subagents). Titles and parents go into a new plain threads table in memory.db; the FTS5 turns table keeps the 0.5.x schema, so old versions and existing databases keep working without a rebuild. --reindex records titles and parents too, and --mcp-stdio routes its calls through DispatchTool (scope folder = its cwd). Smoke: scripts/smoke/history.py (old-format DB, reindex, scope, exclusion). threads.py and subagents.py now wait for their own port before the first control call, so they can't reach a neighbouring gritcode. Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 21 ++++- scripts/smoke/history.py | 176 +++++++++++++++++++++++++++++++++++++ scripts/smoke/subagents.py | 10 ++- scripts/smoke/threads.py | 10 ++- src/chat_frame.cpp | 8 +- src/main.cpp | 9 +- src/mcp_stdio.cpp | 41 ++++----- src/memory.cpp | 48 ++++++++-- src/memory.h | 13 ++- src/tools.cpp | 31 +++++-- src/tools.h | 9 +- 11 files changed, 315 insertions(+), 61 deletions(-) create mode 100644 scripts/smoke/history.py diff --git a/docs/design/threads.md b/docs/design/threads.md index fcec7aa..2948300 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -110,6 +110,25 @@ red and restore the previous text. Nothing is generated unasked: a new thread in a folder with other threads and no brief only shows a one-line hint linking to the dialog. +## 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 lists the current folder's threads (running ones marked, @@ -135,6 +154,6 @@ the decision tray come later (see `orchestrator-conversation.md`). threads with state (running, waiting, failed, 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. -7. Project-scoped history search for the memory tool. +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/smoke/history.py b/scripts/smoke/history.py new file mode 100644 index 0000000..2ff45d6 --- /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(base_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"] or t["waiting"] 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/subagents.py b/scripts/smoke/subagents.py index c3bb217..4ad8712 100644 --- a/scripts/smoke/subagents.py +++ b/scripts/smoke/subagents.py @@ -17,6 +17,7 @@ import os import random import shutil +import socket import stat import subprocess import sys @@ -59,12 +60,13 @@ def launch(): 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) - pid, g = int(out), Grit(base_port=PORT) + # Wait for this instance's own port: Grit would also try the next ones, + # where the user's own gritcode may listen. for _ in range(100): try: - g.status() - return pid, g - except (ConnectionError, OSError): + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + return int(out), Grit(base_port=PORT) + except OSError: time.sleep(0.2) sys.exit("gritcode did not start") diff --git a/scripts/smoke/threads.py b/scripts/smoke/threads.py index b613e46..13f37d1 100755 --- a/scripts/smoke/threads.py +++ b/scripts/smoke/threads.py @@ -15,6 +15,7 @@ import os import random import shutil +import socket import subprocess import sys import tempfile @@ -41,12 +42,13 @@ 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) - pid, g = int(out), Grit(base_port=PORT) + # Wait for this instance's own port: Grit would also try the next ones, + # where the user's own gritcode may listen. for _ in range(100): try: - g.status() - return pid, g - except (ConnectionError, OSError): + socket.create_connection(("127.0.0.1", PORT), timeout=1).close() + return int(out), Grit(base_port=PORT) + except OSError: time.sleep(0.2) sys.exit("gritcode did not start") diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 93d4848..0314e61 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -2495,7 +2495,8 @@ void ChatFrame::Persist(Thread& t) { persistWorker_ = std::thread([this, entry, msgs = std::move(msgs)]() { store_.WriteSessionFile(entry, msgs); if (memory_.IsOpen()) - memory_.RebuildSession(entry.id, entry.cwd, msgs, entry.lastUsed); + memory_.RebuildSession(entry.id, entry.cwd, entry.title, entry.parent, + msgs, entry.lastUsed); }); } @@ -2553,7 +2554,8 @@ std::string ChatFrame::FolderContext(const Thread& t) const { if (!list.empty()) { if (!out.empty()) out += "\n\n"; out += "Other threads in this folder: separate conversations whose " - "history isn't available here." + list; + "history isn't available here; grit_history_search with scope " + "\"folder\" finds what was said and done in them." + list; } return out; } @@ -4956,7 +4958,7 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, r = std::move(job.parseError); } else { r = DispatchTool(job.name, job.argsParsed, token.get(), - &memory_, cwd, enableGrit); + &memory_, cwd, enableGrit, id); } results.push_back({std::move(job.id), std::move(job.name), std::move(job.argsJson), std::move(r)}); 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_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/tools.cpp b/src/tools.cpp index 6efc856..13245a1 100644 --- a/src/tools.cpp +++ b/src/tools.cpp @@ -1056,21 +1056,27 @@ nlohmann::json GetToolDefinitions(bool includeGritHistory, bool subagent) { 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); 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"}}})); @@ -1290,7 +1296,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."; @@ -1299,7 +1306,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); } @@ -1327,7 +1339,8 @@ 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, currentCwd)); @@ -1345,7 +1358,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 6ac46f3..cf656e4 100644 --- a/src/tools.h +++ b/src/tools.h @@ -35,18 +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). +// 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` is the +// calling thread, 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 From af2be564ded507f0341ab86cb4db6c84624b5933 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 20 Oct 2026 00:11:07 +0200 Subject: [PATCH 19/33] test: the control client connects only to the port it is given Smoke tests passed a base port and the client then tried the next five ports too, which reached the user's own gritcode windows on 8765/8766. Co-Authored-By: Claude Opus 5.5 --- scripts/grit.py | 12 ++++++++---- scripts/smoke/brief.py | 2 +- scripts/smoke/subagents.py | 2 +- scripts/smoke/threads.py | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/grit.py b/scripts/grit.py index 590143c..4ee09e3 100644 --- a/scripts/grit.py +++ b/scripts/grit.py @@ -21,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) @@ -36,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} diff --git a/scripts/smoke/brief.py b/scripts/smoke/brief.py index ab2293e..581aa9a 100644 --- a/scripts/smoke/brief.py +++ b/scripts/smoke/brief.py @@ -72,7 +72,7 @@ def hint_shown(g): break except OSError: time.sleep(0.2) - g = Grit(base_port=PORT) + 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") diff --git a/scripts/smoke/subagents.py b/scripts/smoke/subagents.py index c3bb217..a2821a1 100644 --- a/scripts/smoke/subagents.py +++ b/scripts/smoke/subagents.py @@ -59,7 +59,7 @@ def launch(): 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) - pid, g = int(out), Grit(base_port=PORT) + pid, g = int(out), Grit(port=PORT) for _ in range(100): try: g.status() diff --git a/scripts/smoke/threads.py b/scripts/smoke/threads.py index b613e46..ea95931 100755 --- a/scripts/smoke/threads.py +++ b/scripts/smoke/threads.py @@ -41,7 +41,7 @@ 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) - pid, g = int(out), Grit(base_port=PORT) + pid, g = int(out), Grit(port=PORT) for _ in range(100): try: g.status() From 69d5657da0d0da7030e6f8b6e81831b8262f5ac1 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 20 Oct 2026 00:12:43 +0200 Subject: [PATCH 20/33] feat(threads): thread pane (step 5) An optional fixed-width pane left of the splitters lists the active folder's threads, most recent first with subagents nested: a state mark (running, waiting, failed until viewed), the title and a dim line with the current step (tool, writing, thinking, waiting for ), the failure, or when it was last used. Clicking a row selects the thread, right-click stops a running one, "New thread" sits on top. The dropdown and the pane share one row list (FolderRows). The toggle is remembered in preferences and grows the window (or shares a tiled one) like the other panes. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + assets/icons/threads.svg | 5 + docs/design/threads.md | 20 +++- scripts/smoke/thread_pane.py | 147 ++++++++++++++++++++++++ src/chat_frame.cpp | 213 ++++++++++++++++++++++++++++++----- src/chat_frame.h | 24 +++- src/mcp_server.cpp | 4 +- src/mcp_server.h | 7 +- src/preferences.cpp | 13 +++ src/preferences.h | 4 + src/thread_list.cpp | 63 +++++++++++ src/thread_list.h | 31 +++++ 12 files changed, 495 insertions(+), 37 deletions(-) create mode 100644 assets/icons/threads.svg create mode 100644 scripts/smoke/thread_pane.py create mode 100644 src/thread_list.cpp create mode 100644 src/thread_list.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 44ac320..4355d92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -255,6 +255,7 @@ set(SRCS 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/assets/icons/threads.svg b/assets/icons/threads.svg new file mode 100644 index 0000000..74d07b6 --- /dev/null +++ b/assets/icons/threads.svg @@ -0,0 +1,5 @@ + + + diff --git a/docs/design/threads.md b/docs/design/threads.md index fcec7aa..857f46a 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -114,8 +114,22 @@ linking to the dialog. The session dropdown lists the current folder's threads (running ones marked, subagents indented under their parent) plus "New thread", "Project brief…" -and "Open folder…". A left thread pane, the agent map and -the decision tray come later (see `orchestrator-conversation.md`). +and "Open folder…". + +The thread pane (toolbar toggle, remembered in preferences) shows the same +rows at the far left: a "New thread" button, then per thread a state mark +(● running, ○ waiting for the folder, ⚠ failed until viewed) with the title, +and a dim line below: while running the current step (the tool it runs, +"writing", "thinking", "waiting for "), after a failure its notice, +otherwise when it was last used. Both are built from one row list +(`FolderRows`), so they always agree; 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 pane only while a thread of +the folder runs. Clicking a row selects the thread, right-click on a running +one offers "Stop". The pane is a fixed-width panel beside the splitters (no +sash): opening it grows the window by its width, or shares a tiled window, +like the other panes. The agent map and the decision tray come later (see +`orchestrator-conversation.md`). ## Steps @@ -132,7 +146,7 @@ the decision tray come later (see `orchestrator-conversation.md`). list of the folder's other threads in every thread's system prompt. (done) 5. Thread pane: an optional left list of the folder's threads and their child - threads with state (running, waiting, failed, done). + threads with state (running, waiting, failed, done). (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. 7. Project-scoped history search for the memory tool. diff --git a/scripts/smoke/thread_pane.py b/scripts/smoke/thread_pane.py new file mode 100644 index 0000000..4bd678f --- /dev/null +++ b/scripts/smoke/thread_pane.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Smoke test for the thread pane: its rows, toggle and remembered state. + +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 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 + +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) +# The pane was left open last time. +os.makedirs(os.path.dirname(CONF)) +with open(CONF, "w") as f: + f.write("[UI]\nShowThreadPane=1\n") + +env = dict(os.environ, GRIT_TEST_DATA=DATA) +pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}"], + 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(base_port=PORT) + + print("-- rows") + p = panes(g) + check(p["threads"] > 0, "the remembered pane is open at startup") + 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) + if any(t["id"] == a and t["running"] for t in g.list_threads()["threads"]): + detail = rows(g)[b]["detail"] + check(detail.startswith("waiting for "), f"a queued row says what it waits for ({detail})") + else: + print("skip the first turn already ended; no queued row to check") + 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("-- toggle") + before = panes(g) + g._call("togglePanel", {"panel": "threads"}) + time.sleep(0.5) + after = panes(g) + check(after["threads"] == 0, "toggling hides the pane") + check(after["chat"] == before["chat"], "the chat pane keeps its width") + check(after["window"] < before["window"], "the window shrinks") + check("ShowThreadPane=0" in open(CONF).read(), "the closed pane is remembered") + g._call("togglePanel", {"panel": "threads"}) + time.sleep(0.5) + check(panes(g)["threads"] > 0 and len(rows(g)) >= 5, "toggling shows it again, filled") + + 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/src/chat_frame.cpp b/src/chat_frame.cpp index c14a188..e52075b 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -84,6 +84,7 @@ constexpr int ID_EDITOR_RELOAD = wxID_HIGHEST + 23; constexpr int ID_EDITOR_CLOSE = wxID_HIGHEST + 24; constexpr int ID_EDITOR_SHOW_IN_FILES = wxID_HIGHEST + 25; constexpr int ID_MODEL_REFRESH = wxID_HIGHEST + 26; +constexpr int ID_THREADS = wxID_HIGHEST + 29; // Side-panel widths (pixels). The import pane lives in the outer splitter and // the editor pane lives in the inner (chat | editor) splitter, so the window @@ -93,6 +94,9 @@ 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. +constexpr int kThreadPaneWidth = 240; // Chat-pane widths below which toolbar items hide, least important first. constexpr int kToolbarLabelsW = 640; // "Thread:" / "Model:" captions constexpr int kToolbarSettingsW = 500; // settings (and debug Log) button @@ -811,6 +815,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) { @@ -1137,6 +1152,11 @@ ChatFrame::ChatFrame() wxBORDER_NONE); hamburgerBtn_->SetToolTip(wxString::FromUTF8("Toggle session reference")); + wxBitmapBundle bbThreads = LoadThemedSvgIcon("threads.svg", kIconSize, accent); + threadsBtn_ = new wxBitmapButton(panel, ID_THREADS, bbThreads, + wxDefaultPosition, kBtnSize, wxBORDER_NONE); + threadsBtn_->SetToolTip(wxString::FromUTF8("Toggle thread list")); + sessionLabel_ = new wxStaticText(panel, wxID_ANY, "Thread:"); sessionChoice_ = new wxChoice(panel, ID_SESSION); sessionChoice_->SetMinSize(FromDIP(wxSize(160, -1))); @@ -1168,6 +1188,7 @@ ChatFrame::ChatFrame() wxBORDER_NONE); playBtn_->SetToolTip(wxString::FromUTF8("Build and run project")); + toolbarRow->Add(threadsBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); toolbarRow->Add(hamburgerBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); toolbarRow->Add(sessionLabel_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); // Session and model both get a small proportion so they share resize delta; @@ -1391,6 +1412,31 @@ ChatFrame::ChatFrame() e.Skip(); }); + // Thread pane — left of the splitters, hidden unless toggled on. + threadPane_ = new wxPanel(this); + { + auto* newBtn = new wxButton(threadPane_, wxID_ANY, "New thread"); + newBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { NewThread(activeCwd_); }); + threadList_ = new ThreadList(threadPane_); + // 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(newBtn, 0, wxEXPAND | wxBOTTOM, 4); + sizer->Add(threadList_, 1, wxEXPAND); + threadPane_->SetSizer(sizer); + threadPane_->SetMinSize(wxSize(kThreadPaneWidth, -1)); + threadPane_->Show(Preferences::GetShowThreadPane()); + } + 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 @@ -1398,10 +1444,13 @@ ChatFrame::ChatFrame() &ChatFrame::OnInnerSashChanging, this); auto* frameSizer = new wxBoxSizer(wxHORIZONTAL); + frameSizer->Add(threadPane_, 0, wxEXPAND | wxTOP | wxBOTTOM | wxLEFT, FromDIP(kEdgePad)); // Top/bottom padding wraps the whole splitter so its sashes are inset // from the window edges instead of running edge-to-edge. frameSizer->Add(splitter_, 1, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(kEdgePad)); SetSizer(frameSizer); + // A remembered open pane widens the initial window by its width. + SetSize(GetSize().x + ThreadPaneW(), GetSize().y); { PERF_SCOPE("SessionStore::Init"); store_.Init(); } // Open the FTS5 memory index. Failure (e.g. read-only home) is silent; @@ -1456,6 +1505,7 @@ ChatFrame::ChatFrame() Bind(wxEVT_BUTTON, &ChatFrame::OnExport, this, ID_EXPORT); Bind(wxEVT_BUTTON, &ChatFrame::OnHamburger, this, ID_HAMBURGER); Bind(wxEVT_BUTTON, &ChatFrame::OnEditorToggle, this, ID_EDITOR); + Bind(wxEVT_BUTTON, &ChatFrame::OnThreadPaneToggle, this, ID_THREADS); fileTree_->Bind(wxEVT_TREE_ITEM_EXPANDING, &ChatFrame::OnEditorTreeExpanding, this); fileTree_->Bind(wxEVT_TREE_ITEM_COLLAPSED, &ChatFrame::OnEditorTreeCollapsed, this); fileTree_->Bind(wxEVT_TREE_SEL_CHANGED, &ChatFrame::OnEditorTreeSelect, this); @@ -1803,7 +1853,13 @@ 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}}); return {{"window", GetClientSize().x}, + {"threads", threadPane_->IsShown() ? threadPane_->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}}; @@ -1812,6 +1868,7 @@ ChatFrame::ChatFrame() return guiSync([this, panel, panes]() -> nlohmann::json { wxCommandEvent ev(wxEVT_BUTTON); if (panel == "editor") OnEditorToggle(ev); + else if (panel == "threads") OnThreadPaneToggle(ev); else OnHamburger(ev); return panes(); }); @@ -1840,7 +1897,7 @@ ChatFrame::ChatFrame() SetDropTarget(new FrameFileDropTarget(this)); input_->SetFocus(); - SetMinClientSize(wxSize(kMainMinClientW, 400)); + SetMinClientSize(wxSize(kMainMinClientW + ThreadPaneW(), 400)); #ifdef __WXOSX__ // Tahoe look: native styling only, the layout above stays shared. @@ -2235,6 +2292,7 @@ void ChatFrame::ApplyTheme() { importCanvas_->SetBgColour(wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW)); importCanvas_->ApplyTheme(); changeBtn_->SetForegroundColour(importCanvas_->GetPalette().toolAccent); + threadList_->Refresh(); ReloadToolbarIcons(); if (!Active().pendingQueue.empty()) RebuildChips(); @@ -2252,6 +2310,7 @@ void ChatFrame::ReloadToolbarIcons() { exportBtn_->SetBitmap(LoadThemedSvgIcon("export.svg", kIconSize, accent)); hamburgerBtn_->SetBitmap(LoadThemedSvgIcon("hamburger.svg", kIconSize, accent)); editorBtn_->SetBitmap(LoadThemedSvgIcon("editor.svg", kIconSize, accent)); + threadsBtn_->SetBitmap(LoadThemedSvgIcon("threads.svg", kIconSize, accent)); } ChatFrame::Thread* ChatFrame::Find(const std::string& id) { @@ -2280,50 +2339,103 @@ wxString ChatFrame::ThreadLabelOf(const std::string& id) { return e != entries.end() ? ThreadLabel(e->id, e->cwd, e->title) : wxString("thread"); } -void ChatFrame::RefreshSessionChoice() { - PERF_SCOPE("RefreshSessionChoice"); +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 { - std::string id; + ThreadRow row; std::string parent; - wxString label; std::string lastUsed; }; std::vector rows; - for (const Thread& t : threads_) { + for (Thread& t : threads_) { if (t.cwd != activeCwd_) continue; - wxString label = ThreadLabel(t.id, t.cwd, t.title); - if (t.streaming) label = wxString::FromUTF8("\xE2\x97\x8F ") + label; // "● " - else if (t.failureNotice && !Shown(t)) - label = wxString::FromUTF8("\xE2\x9A\xA0 ") + label; // "⚠ ", until viewed - rows.push_back({t.id, t.parent, label, t.lastUsed}); + ThreadRow r{.id = t.id, .title = ThreadLabel(t.id, t.cwd, t.title), + .detail = LastUsedLabel(t.lastUsed)}; + if (t.streaming) { + r.state = t.waiting ? ThreadRow::State::Waiting : 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'); + } + rows.push_back({std::move(r), t.parent, t.lastUsed}); } for (const auto& e : store_.List()) { - if (e.cwd == activeCwd_ && !Find(e.id)) - rows.push_back({e.id, e.parent, ThreadLabel(e.id, e.cwd, e.title), e.lastUsed}); + 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}); } std::ranges::stable_sort(rows, std::greater{}, &Row::lastUsed); - // Subagent threads go right under their parent, indented. - std::vector ordered; + // Subagent threads go right under their parent. auto listed = [&rows](const std::string& id) { - return std::ranges::find(rows, id, &Row::id) != rows.end(); + 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); - for (const Row& c : rows) - if (c.parent == r.id) ordered.push_back(&c); + 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.waiting) { + const Thread* r = RunningIn(t.cwd, t); + return "waiting for " + (r ? ThreadLabel(r->id, r->cwd, r->title) + : wxString("the folder")); + } + 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"); + std::vector rows = FolderRows(); sessionChoice_->Clear(); threadRowIds_.clear(); - for (const Row* r : ordered) { - const bool child = !r->parent.empty() && listed(r->parent); - sessionChoice_->Append(child ? wxString::FromUTF8(" \xE2\x86\xB3 ") + r->label // "↳ " - : r->label); - threadRowIds_.push_back(r->id); + for (const ThreadRow& r : rows) { + wxString label = r.title; + if (r.state == ThreadRow::State::Running || r.state == ThreadRow::State::Waiting) + label = wxString::FromUTF8("\xE2\x97\x8F ") + label; // "● " + else if (r.state == ThreadRow::State::Failed) + label = wxString::FromUTF8("\xE2\x9A\xA0 ") + label; // "⚠ " + if (r.child) label = wxString::FromUTF8(" \xE2\x86\xB3 ") + label; // "↳ " + sessionChoice_->Append(label); + threadRowIds_.push_back(r.id); } sessionChoice_->Append("New thread"); sessionChoice_->Append(wxString::FromUTF8("Project brief\xE2\x80\xA6")); @@ -2331,6 +2443,18 @@ void ChatFrame::RefreshSessionChoice() { auto active = std::ranges::find(threadRowIds_, Active().id); sessionChoice_->SetSelection((int)(active - threadRowIds_.begin())); sessionChoice_->SetToolTip(wxString::FromUTF8(activeCwd_)); + if (threadPane_->IsShown()) UpdateThreadPane(std::move(rows)); +} + +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 || r.state == ThreadRow::State::Waiting; + }); + if (!running) threadPaneTimer_.Stop(); + else if (!threadPaneTimer_.IsRunning()) threadPaneTimer_.Start(1000); + threadList_->SetRows(std::move(rows), Active().id); } void ChatFrame::OnSessionChoice(wxCommandEvent& evt) { @@ -2938,6 +3062,37 @@ void ChatFrame::OnHamburger(wxCommandEvent&) { SyncPanelSizing(centerW, tiled); } +void ChatFrame::OnThreadPaneToggle(wxCommandEvent&) { + // Like the import pane: the chat pane keeps its width, the window grows + // or shrinks (unless tiled). + const bool tiled = hyprland::IsTiled().value_or(false); + int centerW = mainPanel_->GetSize().x; + const bool show = !threadPane_->IsShown(); + threadPane_->Show(show); + Preferences::SetShowThreadPane(show); + if (show) UpdateThreadPane(FolderRows()); + else threadPaneTimer_.Stop(); + SyncPanelSizing(centerW, tiled); +} + +int ChatFrame::ThreadPaneW() const { + return threadPane_->IsShown() ? kThreadPaneWidth + FromDIP(kEdgePad) : 0; +} + +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; @@ -2969,7 +3124,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(ThreadPaneW() + centerW + importW + editorW, + GetClientSize().y)); Layout(); splitter_->UpdateSize(); innerSplitter_->UpdateSize(); @@ -2999,8 +3155,9 @@ void ChatFrame::SyncPanelSizing(int centerW, bool tiled) { int editorMinW = innerSplitter_->IsSplit() ? innerSplitter_->GetMinimumPaneSize() + innerSplitter_->GetSashSize() : 0; - SetMinClientSize(wxSize(kMainMinClientW + importMinW + editorMinW, 400)); - SetClientSize(wxSize(centerW + importW + editorW, GetClientSize().y)); + const int threadW = ThreadPaneW(); + SetMinClientSize(wxSize(kMainMinClientW + threadW + importMinW + editorMinW, 400)); + SetClientSize(wxSize(threadW + centerW + importW + editorW, GetClientSize().y)); Layout(); splitter_->UpdateSize(); innerSplitter_->UpdateSize(); @@ -3014,7 +3171,7 @@ void ChatFrame::LayoutTiledPanes() { ? splitter_->GetMinimumPaneSize() + splitter_->GetSashSize() : 0; int editorMinW = innerSplitter_->IsSplit() ? innerSplitter_->GetMinimumPaneSize() + innerSplitter_->GetSashSize() : 0; - int minW = kMainMinClientW + importMinW + editorMinW; + int minW = kMainMinClientW + ThreadPaneW() + importMinW + editorMinW; SetMinClientSize(wxSize(std::min(minW, GetClientSize().x), 400)); Layout(); splitter_->UpdateSize(); diff --git a/src/chat_frame.h b/src/chat_frame.h index 8fa36a3..6d6d9bf 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -20,6 +20,7 @@ #include "memory.h" #include "session_store.h" #include "streaming_web_request.h" +#include "thread_list.h" #include "tools.h" #include #include @@ -96,6 +97,7 @@ class ChatFrame : public wxFrame { wxBitmapButton* exportBtn_ = nullptr; wxBitmapButton* hamburgerBtn_ = nullptr; wxBitmapButton* editorBtn_ = nullptr; + wxBitmapButton* threadsBtn_ = nullptr; // Streaming tool_call accumulator. Each delta carries an `index` field that // identifies which call the (id/name/arguments) fragments belong to. @@ -459,9 +461,29 @@ class ChatFrame : public wxFrame { // Repopulate the thread dropdown: the active folder's threads, most // recent first (running ones marked), then "New thread" and - // "Open folder…", and select the active thread. + // "Open folder…", and select the active thread. Refreshes the thread + // pane too. void RefreshSessionChoice(); + // ---- Thread pane (optional, left of everything) ---- + // The active folder's threads with their state; the selected row is the + // active thread. Hidden unless toggled on (remembered in preferences). + wxPanel* threadPane_ = nullptr; + 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; shared by the dropdown and the pane. + 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); + void OnThreadPaneToggle(wxCommandEvent&); + void OnThreadListContextMenu(wxContextMenuEvent& e); + // Width the thread pane takes from the window (0 when hidden). + int ThreadPaneW() const; + // 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`. diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp index bcca27c..ce1a61b 100644 --- a/src/mcp_server.cpp +++ b/src/mcp_server.cpp @@ -193,8 +193,8 @@ json MCPServer::HandleRequest(const json& request) { 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") { diff --git a/src/mcp_server.h b/src/mcp_server.h index a63fce9..779411f 100644 --- a/src/mcp_server.h +++ b/src/mcp_server.h @@ -100,12 +100,13 @@ 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(). + // 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/preferences.cpp b/src/preferences.cpp index f1b8aac..76a020e 100644 --- a/src/preferences.cpp +++ b/src/preferences.cpp @@ -21,6 +21,7 @@ const wxString kUsername = "api_key"; const char* kModelIndexKey = "/UI/LastModelIndex"; const char* kModelExplicitKey = "/UI/ModelExplicit"; const char* kEnableGritKey = "/UI/EnableGritHistory"; +const char* kThreadPaneKey = "/UI/ShowThreadPane"; const char* kReasoningEffortKey = "/DeepSeek/ReasoningEffort"; const char* kClaudeEffortKey = "/Claude/Effort"; @@ -123,6 +124,18 @@ bool Preferences::GetEnableGritHistory() { return v; } +bool Preferences::GetShowThreadPane() { + bool v = false; + wxConfigBase::Get()->Read(kThreadPaneKey, &v, false); + return v; +} + +void Preferences::SetShowThreadPane(bool show) { + auto* cfg = wxConfigBase::Get(); + cfg->Write(kThreadPaneKey, show); + cfg->Flush(); +} + void Preferences::SetEnableGritHistory(bool enabled) { auto* cfg = wxConfigBase::Get(); if (!cfg) return; diff --git a/src/preferences.h b/src/preferences.h index 8b6f9be..0db1cb8 100644 --- a/src/preferences.h +++ b/src/preferences.h @@ -40,6 +40,10 @@ class Preferences { static bool GetEnableGritHistory(); static void SetEnableGritHistory(bool enabled); + // Whether the thread pane is open. Defaults to false. + static bool GetShowThreadPane(); + static void SetShowThreadPane(bool show); + // 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/thread_list.cpp b/src/thread_list.cpp new file mode 100644 index 0000000..d1652a9 --- /dev/null +++ b/src/thread_list.cpp @@ -0,0 +1,63 @@ +#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) { + dc.SetTextForeground(selected ? fg : omarchy::Readable(accent, bg, 3.0)); + dc.DrawText(wxString::FromUTF8("\xE2\x97\x8F"), x, y); // "●" + } else if (r.state == Waiting) { + dc.SetTextForeground(dim); + dc.DrawText(wxString::FromUTF8("\xE2\x97\x8B"), 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..0159ddb --- /dev/null +++ b/src/thread_list.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include + +// One row of the thread pane. +struct ThreadRow { + enum class State { Done, Running, Waiting, 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; +}; From 4e5a5cf7162f1f6991c3ef0d6b0d3598d13cb9a7 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 20 Oct 2026 00:13:36 +0200 Subject: [PATCH 21/33] test(thread_pane): exact-port control client Co-Authored-By: Claude Opus 5.5 --- scripts/smoke/thread_pane.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke/thread_pane.py b/scripts/smoke/thread_pane.py index 4bd678f..b0d5323 100644 --- a/scripts/smoke/thread_pane.py +++ b/scripts/smoke/thread_pane.py @@ -83,7 +83,7 @@ def day(iso): break except OSError: time.sleep(0.2) - g = Grit(base_port=PORT) + g = Grit(port=PORT) print("-- rows") p = panes(g) From 5e83235a73ce1d4887cccd38e6fc1c0e70191475 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 20 Oct 2026 00:24:19 +0200 Subject: [PATCH 22/33] wip(threads): worktree threads (step 6) Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 + scripts/grit.py | 7 +- scripts/smoke/worktrees.py | 226 ++++++++++++++++++++++++ src/chat_frame.cpp | 348 ++++++++++++++++++++++++++++++++----- src/chat_frame.h | 49 +++++- src/git_worktree.cpp | 272 +++++++++++++++++++++++++++++ src/git_worktree.h | 61 +++++++ src/mcp_server.cpp | 9 +- src/mcp_server.h | 19 +- src/session_store.cpp | 12 +- src/session_store.h | 11 ++ src/thread_list.h | 1 + 12 files changed, 958 insertions(+), 58 deletions(-) create mode 100644 scripts/smoke/worktrees.py create mode 100644 src/git_worktree.cpp create mode 100644 src/git_worktree.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4355d92..9f06613 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -253,6 +253,7 @@ set(SRCS src/preferences.cpp src/settings_dialog.cpp src/project_brief.cpp + src/git_worktree.cpp src/streaming_web_request.cpp src/run_config_store.cpp src/thread_list.cpp diff --git a/scripts/grit.py b/scripts/grit.py index 4ee09e3..977584c 100644 --- a/scripts/grit.py +++ b/scripts/grit.py @@ -95,8 +95,11 @@ def new_session(self): def list_threads(self): return self._call("listThreads") - def new_thread(self, cwd=""): - return self._call("newThread", {"cwd": cwd}) + def new_thread(self, cwd="", worktree=False): + return self._call("newThread", {"cwd": cwd, "worktree": worktree}) + + def worktree_action(self, action, confirm=False): + return self._call("worktreeAction", {"action": action, "confirm": confirm}) def select_thread(self, thread_id): return self._call("selectThread", {"id": thread_id}) diff --git a/scripts/smoke/worktrees.py b/scripts/smoke/worktrees.py new file mode 100644 index 0000000..53941c8 --- /dev/null +++ b/scripts/smoke/worktrees.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +Smoke test for worktree threads, in a temp git repo with the fake model: +a worktree thread writes alongside the folder's main thread, review shows its +diff, merge brings it in, a conflicting merge aborts cleanly, discard asks +before losing work, and a worktree removed by hand is reported. + +Usage: + python3 scripts/smoke/worktrees.py build/gritcode + +Runs the app only through ~/.local/share/grit-dev/run-hidden.sh (invisible +Xvfb, temp HOME and data dir). +""" + +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 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") +MODEL_URL = serve() +DATA = tempfile.mkdtemp(prefix="grit-worktrees-") +PORT = int(os.environ.get("GRIT_MCP_PORT") or random.randint(18600, 18699)) +SESSIONS = os.path.join(DATA, "gritcode", "sessions") +REPO = os.path.join(DATA, "repo") +failures = 0 + + +def launch(): + env = dict(os.environ, GRIT_TEST_DATA=DATA) + out = subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", + f"GRIT_FREE_MODEL_URL={MODEL_URL}"], + 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 git(*args): + return subprocess.run(["git", "-C", REPO, *args], capture_output=True, text=True).stdout.strip() + + +def write(path, text): + with open(path, "w") as f: + f.write(text + "\n") + + +def read(path): + try: + with open(path) as f: + return f.read().strip() + except OSError: + return "" + + +def threads(g): + return {t["id"]: t for t in g.list_threads()["threads"]} + + +def wait_idle(g, timeout=120): + end = time.time() + timeout + while time.time() < end: + if not any(t["running"] or t["waiting"] for t in threads(g).values()): + return True + time.sleep(0.5) + return False + + +def bash(cmd): + return f"Call the bash tool now with exactly this command: {cmd}" + + +def previews(g): + return " ".join(b["preview"] for b in g.blocks()) + + +os.makedirs(REPO) +git("init", "-q", "-b", "main") +git("config", "user.name", "Smoke Test") +git("config", "user.email", "smoke@example.invalid") +write(os.path.join(REPO, "README"), "readme") +write(os.path.join(REPO, "shared.txt"), "base") +git("add", "-A") +git("commit", "-q", "-m", "initial") + +pid, g = launch() +try: + g.set_model(0) + main = g.switch_session(REPO)["threadId"] + + print("-- creating a worktree thread") + w = g.new_thread(REPO, worktree=True)["threadId"] + t = threads(g)[w] + wt, branch = t["worktree"], t["branch"] + check(branch.startswith("grit/") and wt.startswith(os.path.join(DATA, "gritcode", "worktrees")), + "it has a branch and a worktree outside the repo") + check(os.path.isdir(wt) and wt in git("worktree", "list"), "git knows the worktree") + check(t["cwd"] == REPO, "it belongs to the project folder") + check(f"isolated git worktree of {REPO}" in g.conversation()[0]["content"] + and branch in g.conversation()[0]["content"], "its system prompt says so") + check("Review changes" in previews(g), "its canvas offers the actions") + with open(os.path.join(SESSIONS, w + ".json")) as f: + saved = json.load(f) + check(saved.get("worktree") == wt and saved.get("branch") == branch, + "the session file records worktree and branch") + g._call("togglePanel", {"panel": "threads"}) + rows = {r["id"]: r for r in g._call("getPanes")["threadRows"]} + check(branch in rows[w]["detail"], "its pane row shows the branch") + + print("-- writing alongside the folder's main thread") + g.select_thread(main) + g.send(bash("sleep 3; echo main > main.txt")) + g.select_thread(w) + g.send(bash("sleep 3; pwd > wt_ran.txt; echo feature > feature.txt")) + now = threads(g) + check(now[main]["running"] and now[w]["running"] and not now[w]["waiting"], + "both run at the same time") + check(wait_idle(g), "both turns finished") + check(read(os.path.join(REPO, "main.txt")) == "main", "the main thread wrote in the repo") + check(read(os.path.join(wt, "wt_ran.txt")) == wt and read(os.path.join(wt, "feature.txt")) == "feature", + "the worktree thread wrote in its worktree") + check(not os.path.exists(os.path.join(REPO, "feature.txt")), "the repo doesn't have its file yet") + + print("-- review") + r = g.worktree_action("review")["notice"] + check("feature.txt" in r and "+feature" in r, "review lists the new file and its diff") + check(any(b["type"] == "code" and "+feature" in b["preview"] for b in g.blocks()), + "the diff shows in the canvas") + + print("-- merge") + head = git("rev-parse", "HEAD") + r = g.worktree_action("merge")["notice"] + check(f"Merged {branch} into main" in r and "committed" in r, "merge reports what it did") + check(read(os.path.join(REPO, "feature.txt")) == "feature", "the change is in the repo") + check(git("rev-parse", "HEAD^1") == head and git("log", "-1", "--format=%s").startswith("Merge"), + "as a merge commit on main") + + print("-- merge refused while the repo has uncommitted changes") + write(os.path.join(REPO, "README"), "edited by the user") + head = git("rev-parse", "HEAD") + r = g.worktree_action("merge")["notice"] + check("uncommitted changes" in r and "README" in r, "it says why") + check(git("rev-parse", "HEAD") == head and read(os.path.join(REPO, "README")) == "edited by the user", + "nothing changed") + git("checkout", "README") + + print("-- a conflicting merge") + write(os.path.join(REPO, "shared.txt"), "project side") + git("commit", "-q", "-am", "project side") + g.send(bash("echo worktree side > shared.txt")) + check(wait_idle(g), "the turn finished") + head = git("rev-parse", "HEAD") + r = g.worktree_action("merge")["notice"] + check("conflicts in shared.txt" in r and "aborted" in r, "it reports the conflict and the abort") + check(any(b["preview"].startswith(r[:100]) for b in g.blocks()), "the notice shows in the canvas") + check(git("rev-parse", "HEAD") == head and git("status", "--porcelain", "--untracked-files=no") == "" + and read(os.path.join(REPO, "shared.txt")) == "project side", + "the repo is as before") + check(subprocess.run(["git", "-C", REPO, "rev-parse", "-q", "--verify", "MERGE_HEAD"], + capture_output=True).returncode != 0, "no merge in progress") + + print("-- discard") + r = g.worktree_action("discard") + check(not r["ok"] and "not merged" in r["asked"], "it asks first (unmerged commit)") + check(os.path.isdir(wt) and git("branch", "--list", branch) != "", "nothing removed without a yes") + r = g.worktree_action("discard", confirm=True) + check(r["ok"] and not os.path.exists(wt) and git("branch", "--list", branch) == "", + "a yes removes worktree and branch") + check(threads(g)[w]["worktree"] == "" and "Review changes" not in previews(g), + "the thread is a plain thread now") + + print("-- a worktree removed by hand") + w2 = g.new_thread(REPO, worktree=True)["threadId"] + wt2, branch2 = threads(g)[w2]["worktree"], threads(g)[w2]["branch"] + stop(pid) + shutil.rmtree(wt2) + pid, g = launch() + check(threads(g)[w2]["worktree"] == wt2, "worktree threads are restored") + g.select_thread(w2) + check("is gone" in previews(g), "its canvas says the worktree is gone") + g.send("hello") + time.sleep(0.5) + check(not threads(g)[w2]["running"] and "can't run" in previews(g), "it doesn't run") + r = g.worktree_action("discard") + check(r["ok"] and r["asked"] == "", "discarding needs no question (no work)") + check(wt2 not in git("worktree", "list") and git("branch", "--list", branch2) == "", + "the stale worktree is pruned and its branch deleted") +finally: + 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 6d2a178..b7000e5 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -9,6 +9,7 @@ #include "run_config_store.h" #include "settings_dialog.h" #include "project_brief.h" +#include "git_worktree.h" #include "image_store.h" #include "debug_window.h" #include "syntax.h" @@ -462,6 +463,16 @@ constexpr const char* kSubagentNote = "inspect (grep, git log, running tests). Nobody can answer questions, so " "state your assumptions in the report."; +// Added to a worktree thread's system prompt. +std::string WorktreeNote(const std::string& project, const std::string& workDir, + const std::string& branch) { + return "You work in an isolated git worktree of " + project + ": " + workDir + + ", on branch " + branch + ". Your tools run there; make every change " + "there, not in " + project + ", where other threads may be working. The " + "project's instructions and brief above apply as they are. When you are " + "done, the user reviews the branch and merges it into the project."; +} + // Model that writes a project brief: DeepSeek Flash when there is a key (it // is cheap and fast), otherwise the free model. Claude runs through its CLI, // which doesn't fit a single request. @@ -544,16 +555,31 @@ Block NoticeBlock(const wxString& msg) { 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); +// Appends a link to `url` (see OnCanvasLink) to notice `b`. +void AppendLink(Block& b, const wxString& label, const wxString& url) { InlineRun link; link.text = label; link.bold = true; - link.link = wxString::FromUTF8("gritcode://thread/" + id); + link.link = url; b.runs.push_back(link); b.rawText += label; b.visibleText += label; +} + +// Appends plain notice text to `b`. +void AppendText(Block& b, const wxString& text) { + InlineRun r; + r.text = text; + r.italic = true; + b.runs.push_back(r); + b.rawText += text; + b.visibleText += text; +} + +// Notice ending in a link that opens thread `id`. +Block ThreadLinkBlock(const wxString& msg, const std::string& id, const wxString& label) { + Block b = NoticeBlock(msg); + AppendLink(b, label, wxString::FromUTF8("gritcode://thread/" + id)); return b; } @@ -1425,6 +1451,11 @@ ChatFrame::ChatFrame() { auto* newBtn = new wxButton(threadPane_, wxID_ANY, "New thread"); newBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { NewThread(activeCwd_); }); + newWorktreeBtn_ = new wxButton(threadPane_, wxID_ANY, "New thread in worktree"); + newWorktreeBtn_->SetToolTip("A thread that works in its own git worktree, so it can " + "write alongside the folder's other threads"); + newWorktreeBtn_->Bind(wxEVT_BUTTON, + [this](wxCommandEvent&) { NewWorktreeThread(activeCwd_); }); threadList_ = new ThreadList(threadPane_); // Switch after the click is handled: the switch replaces the rows. threadList_->Bind(wxEVT_LISTBOX, [this](wxCommandEvent& e) { @@ -1436,6 +1467,7 @@ ChatFrame::ChatFrame() threadList_->Bind(wxEVT_CONTEXT_MENU, &ChatFrame::OnThreadListContextMenu, this); auto* sizer = new wxBoxSizer(wxVERTICAL); sizer->Add(newBtn, 0, wxEXPAND | wxBOTTOM, 4); + sizer->Add(newWorktreeBtn_, 0, wxEXPAND | wxBOTTOM, 4); sizer->Add(threadList_, 1, wxEXPAND); threadPane_->SetSizer(sizer); threadPane_->SetMinSize(wxSize(kThreadPaneWidth, -1)); @@ -1663,20 +1695,45 @@ ChatFrame::ChatFrame() {"failed", t && t->failureNotice.has_value()}, }); }; - for (const Thread& t : threads_) + 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); + arr.back()["worktree"] = t.worktree; + arr.back()["branch"] = t.branch; + } + for (const auto& e : store_.List()) { + if (Find(e.id)) continue; + add(e.id, e.cwd, e.title, e.parent, e.lastUsed, nullptr); + arr.back()["worktree"] = e.worktree; + arr.back()["branch"] = e.branch; + } 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"}}; + cb.newThread = [this, guiSync](const std::string& cwd, bool worktree) -> nlohmann::json { + return guiSync([this, cwd, worktree]() -> nlohmann::json { + const std::string folder = cwd.empty() ? activeCwd_ : cwd; + if (!(worktree ? NewWorktreeThread(folder) : NewThread(folder))) + return {{"ok", false}, {"reason", worktree ? "worktree failed" : "cancelled"}}; return {{"ok", true}, {"threadId", Active().id}}; }); }; + cb.worktreeAction = [this, guiSync](const std::string& action, + bool confirm) -> nlohmann::json { + return guiSync([this, action, confirm]() -> nlohmann::json { + Thread& t = Active(); + if (t.worktree.empty()) return {{"ok", false}, {"notice", "not a worktree thread"}}; + if (action == "review") return {{"ok", true}, {"notice", ReviewChanges(t)}}; + if (action == "merge") return {{"ok", true}, {"notice", MergeWorktreeThread(t)}}; + if (action != "discard") return {{"ok", false}, {"notice", "unknown action"}}; + std::string asked; + const auto notice = DiscardWorktree(t, [&](const wxString& question) { + asked = question.utf8_string(); + return confirm; + }); + return {{"ok", notice.has_value()}, {"notice", notice.value_or("")}, + {"asked", asked}}; + }); + }; 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"}}; @@ -2359,7 +2416,7 @@ std::vector ChatFrame::FolderRows() { 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)}; + .detail = LastUsedLabel(t.lastUsed), .branch = t.branch}; if (t.streaming) { r.state = t.waiting ? ThreadRow::State::Waiting : ThreadRow::State::Running; r.detail = CurrentStep(t); @@ -2372,9 +2429,15 @@ std::vector ChatFrame::FolderRows() { 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)}, + .detail = LastUsedLabel(e.lastUsed), .branch = e.branch}, e.parent, e.lastUsed}); } + // A worktree thread shows its branch while it isn't busy. + for (Row& r : rows) { + if (!r.row.branch.empty() && r.row.state == ThreadRow::State::Done) + r.row.detail = wxString::FromUTF8(r.row.branch) + wxString::FromUTF8(" \xC2\xB7 ") + + r.row.detail; // "·" + } std::ranges::stable_sort(rows, std::greater{}, &Row::lastUsed); // Subagent threads go right under their parent. @@ -2396,7 +2459,7 @@ std::vector ChatFrame::FolderRows() { wxString ChatFrame::CurrentStep(Thread& t) { if (t.waiting) { - const Thread* r = RunningIn(t.cwd, t); + const Thread* r = RunningIn(t.workDir, t); return "waiting for " + (r ? ThreadLabel(r->id, r->cwd, r->title) : wxString("the folder")); } @@ -2442,10 +2505,17 @@ void ChatFrame::RefreshSessionChoice() { else if (r.state == ThreadRow::State::Failed) label = wxString::FromUTF8("\xE2\x9A\xA0 ") + label; // "⚠ " if (r.child) label = wxString::FromUTF8(" \xE2\x86\xB3 ") + label; // "↳ " + if (!r.branch.empty()) label += wxString::FromUTF8(" [" + r.branch + "]"); sessionChoice_->Append(label); threadRowIds_.push_back(r.id); } sessionChoice_->Append("New thread"); + const bool git = !FindGitRoot(activeCwd_).empty(); + if (git) sessionChoice_->Append("New thread in worktree"); + if (newWorktreeBtn_->IsShown() != git) { + newWorktreeBtn_->Show(git); + threadPane_->Layout(); + } sessionChoice_->Append(wxString::FromUTF8("Project brief\xE2\x80\xA6")); sessionChoice_->Append(wxString::FromUTF8("Open folder\xE2\x80\xA6")); auto active = std::ranges::find(threadRowIds_, Active().id); @@ -2470,12 +2540,17 @@ void ChatFrame::OnSessionChoice(wxCommandEvent& evt) { const int threadRows = (int)threadRowIds_.size(); const std::string id = sel >= 0 && sel < threadRows ? threadRowIds_[sel] : std::string(); + // The rows after the threads: New thread, New thread in worktree (git + // repos only), Project brief, Open folder. + int action = sel - threadRows; + if (action >= 1 && FindGitRoot(activeCwd_).empty()) ++action; // Snap back to the active thread; a successful switch reselects. RefreshSessionChoice(); if (!id.empty()) SelectThread(id); - else if (sel == threadRows) NewThread(activeCwd_); - else if (sel == threadRows + 1) EditProjectBrief(); - else if (sel == threadRows + 2) CreateNewSession(); + else if (action == 0) NewThread(activeCwd_); + else if (action == 1) NewWorktreeThread(activeCwd_); + else if (action == 2) EditProjectBrief(); + else if (action == 3) CreateNewSession(); } void ChatFrame::CreateNewSession() { @@ -2525,6 +2600,35 @@ bool ChatFrame::NewThread(const std::string& cwd) { return false; } +bool ChatFrame::NewWorktreeThread(const std::string& cwd) { + const std::string id = SessionStore::NewThreadId(); + const std::string branch = "grit/" + id.substr(0, 8); + const std::string path = store_.WorktreePath(id); + if (std::string error = AddWorktree(cwd, path, branch); !error.empty()) { + RenderErrorBlock(Active(), "Couldn't create a worktree: " + wxString::FromUTF8(error)); + return false; + } + Thread& t = OpenThread(id, cwd); + t.worktree = path; + t.branch = branch; + t.workDir = WorktreeDir(cwd, path); + RefreshSystemPrompt(t); + if (!ShowThread(id)) { + threads_.pop_back(); // cancelled; nothing refers to it yet + RemoveWorktree(cwd, path, branch); + return false; + } + // Saved right away, unlike a plain new thread: it owns a worktree, which + // must stay listed (and discardable) even if the thread is never used. + Persist(t); + const std::string from = GitBranch(cwd); + RenderErrorBlock(t, wxString::FromUTF8( + "Created the worktree " + path + " on branch " + branch + " from " + + (from.empty() ? std::string("HEAD") : from) + ". It holds the committed " + "state only: uncommitted changes in " + cwd + " aren't in it.")); + return true; +} + bool ChatFrame::SelectThread(const std::string& id) { if (!Find(id)) { const auto& entries = store_.List(); @@ -2547,7 +2651,10 @@ ChatFrame::Thread& ChatFrame::OpenThread(const std::string& id, t.title = e->title; t.parent = e->parent; t.lastUsed = e->lastUsed; + t.worktree = e->worktree; + t.branch = e->branch; } + t.workDir = t.worktree.empty() ? cwd : WorktreeDir(cwd, t.worktree); if (store_.Load(id, t.history)) { RefreshSystemPrompt(t); // Sessions from before threads have no title yet. @@ -2604,6 +2711,7 @@ void ChatFrame::Persist(Thread& t) { const SessionStore::Entry entry{ .id = t.id, .cwd = t.cwd, .lastUsed = t.lastUsed, .title = t.title, .parent = t.parent, + .worktree = t.worktree, .branch = t.branch, }; std::vector msgs = t.history; @@ -2624,12 +2732,139 @@ void ChatFrame::Persist(Thread& t) { }); } +std::string ChatFrame::ReviewChanges(Thread& t) { + const WorktreeDiff d = ReviewWorktree(t.cwd, t.worktree); + std::string notice; + if (!d.error.empty()) notice = "Can't show the changes: " + d.error; + else if (d.diff.empty()) notice = "No changes on " + t.branch + " yet."; + else notice = "Changes on " + t.branch + " since it branched off (committed and " + "uncommitted):\n" + d.stat; + RenderErrorBlock(t, wxString::FromUTF8(notice)); + if (!d.diff.empty() && Shown(t)) { + // A huge diff is cut at a line end, so the text stays valid UTF-8. + constexpr size_t kMaxShown = 200'000; + std::string text = d.diff; + if (text.size() > kMaxShown) { + text.resize(text.rfind('\n', kMaxShown) + 1); + text += "... [cut; git diff in " + t.worktree + " shows the rest]\n"; + } + Block b; + b.type = BlockType::CodeBlock; + b.lang = "diff"; + b.rawText = b.visibleText = wxString::FromUTF8(text); + InlineRun r; + r.text = b.rawText; + r.code = true; + b.runs.push_back(std::move(r)); + canvas_->AddBlock(std::move(b)); + } + return notice + "\n" + d.diff; +} + +std::string ChatFrame::MergeWorktreeThread(Thread& t) { + std::string notice; + if (t.streaming || HasRunningChild(t)) { + notice = "This thread is still running. Merge when its turn has ended (or stop it)."; + } else if (const Thread* other = RunningIn(t.cwd, t)) { + notice = ThreadLabel(other->id, other->cwd, other->title).utf8_string() + + " is running in the project folder. Merge when it's done, so the " + "merge doesn't change files under it."; + } else { + const std::string message = + t.title.empty() ? "Work from Gritcode thread " + t.id : t.title; + notice = MergeWorktree(t.cwd, t.worktree, t.branch, message).message; + } + RenderErrorBlock(t, wxString::FromUTF8(notice)); + return notice; +} + +std::optional ChatFrame::DiscardWorktree( + Thread& t, const std::function& confirm) { + std::string notice; + if (t.streaming || HasRunningChild(t)) { + notice = "This thread is still running. Discard its worktree when its turn has " + "ended (or stop it)."; + RenderErrorBlock(t, wxString::FromUTF8(notice)); + return notice; + } + // Never lose work silently: ask when the worktree holds anything that + // isn't in the project yet, or when git can't tell. + const WorktreeWork w = UnmergedWork(t.cwd, t.worktree, t.branch); + std::string loses; + if (!w.error.empty()) { + loses = "Gritcode couldn't check what it holds (" + w.error + ")."; + } else if (w.unmergedCommits > 0 || w.changedFiles > 0) { + std::string target = GitBranch(t.cwd); + if (target.empty()) target = "the project"; + if (w.unmergedCommits > 0) + loses = t.branch + " has " + std::to_string(w.unmergedCommits) + " commit(s) not " + "merged into " + target + "."; + if (w.changedFiles > 0) + loses += (loses.empty() ? "" : " ") + std::string("The worktree has uncommitted " + "changes in ") + std::to_string(w.changedFiles) + " file(s)."; + loses += " Discarding deletes them."; + } + if (!loses.empty() + && !confirm(wxString::FromUTF8("Discard the worktree of this thread? " + loses))) + return std::nullopt; + + const std::string path = t.worktree; + const std::string branch = t.branch; + if (std::string error = RemoveWorktree(t.cwd, path, branch); !error.empty()) { + notice = "Couldn't discard the worktree: " + error; + } else { + t.worktree.clear(); + t.branch.clear(); + t.workDir = t.cwd; + RefreshSystemPrompt(t); + Persist(t); + if (Shown(t)) RestoreCanvasFromHistory(); + RefreshSessionChoice(); + notice = "Discarded the worktree " + path + " and branch " + branch + + ". This thread now works in " + t.cwd + "."; + } + RenderErrorBlock(t, wxString::FromUTF8(notice)); + return notice; +} + +void ChatFrame::DiscardWorktreeAsking(Thread& t) { + DiscardWorktree(t, [this](const wxString& question) { + return wxMessageBox(question, "Discard worktree", + wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this) == wxYES; + }); +} + +Block ChatFrame::WorktreeHeader(const Thread& t) { + if (!wxDirExists(wxString::FromUTF8(t.worktree))) { + Block b = NoticeBlock(wxString::FromUTF8( + "This thread's worktree " + t.worktree + " is gone (removed outside Gritcode), " + "so the thread can't run. Its branch " + t.branch + " may still hold committed " + "work: git merge " + t.branch + " in the project brings that in. ")); + AppendLink(b, "Discard worktree", "gritcode://worktree/discard"); + AppendText(b, " deletes the branch, and the thread works in the project folder."); + return b; + } + const std::string target = GitBranch(t.cwd); + Block b = NoticeBlock(wxString::FromUTF8( + "Worktree thread: works in " + t.workDir + " on branch " + t.branch + + ", apart from the folder's other threads. ")); + AppendLink(b, "Review changes", "gritcode://worktree/review"); + AppendText(b, wxString::FromUTF8(" \xC2\xB7 ")); // "·" + AppendLink(b, "Merge into " + wxString::FromUTF8( + target.empty() ? std::string("the project") : target), + "gritcode://worktree/merge"); + AppendText(b, wxString::FromUTF8(" \xC2\xB7 ")); + AppendLink(b, "Discard worktree", "gritcode://worktree/discard"); + return b; +} + std::string ChatFrame::SystemPrompt(Thread& t) { t.lastAgentsContent = LoadAgentsInstructions(t.cwd); std::string content = BaseSystemPrompt(); if (!t.lastAgentsContent.empty()) content += "\n\n" + t.lastAgentsContent; if (std::string folder = FolderContext(t); !folder.empty()) content += "\n\n" + folder; + if (!t.worktree.empty()) content += "\n\n" + WorktreeNote(t.cwd, t.workDir, t.branch); if (!t.parent.empty()) content += kSubagentNote; return content; } @@ -2718,6 +2953,7 @@ void ChatFrame::RestoreCanvasFromHistory() { if (!t.parent.empty()) canvas_->AddBlock(ThreadLinkBlock("Subagent thread (view only), started by ", t.parent, ThreadLabelOf(t.parent))); + if (!t.worktree.empty()) canvas_->AddBlock(WorktreeHeader(t)); ModelSwitchTracker modelTracker; // Walk the message list and re-emit blocks. Tool call/result pairs are // stitched back together: an assistant message's tool_calls are matched @@ -3054,6 +3290,11 @@ void ChatFrame::OnCanvasLink(wxCommandEvent& e) { SelectThread(threadId.utf8_string()); } else if (e.GetString() == "gritcode://brief") { EditProjectBrief(); + } else if (wxString action; e.GetString().StartsWith("gritcode://worktree/", &action) + && !Active().worktree.empty()) { + if (action == "review") ReviewChanges(Active()); + else if (action == "merge") MergeWorktreeThread(Active()); + else if (action == "discard") DiscardWorktreeAsking(Active()); } } @@ -3094,13 +3335,30 @@ void ChatFrame::OnThreadListContextMenu(wxContextMenuEvent& e) { ? 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; + const ThreadRow row = threadList_->Rows()[n]; + const Thread* t = Find(row.id); + enum { ID_REVIEW = wxID_HIGHEST + 1, ID_MERGE, ID_DISCARD }; wxMenu menu; - menu.Append(wxID_STOP, "Stop"); - if (threadList_->GetPopupMenuSelectionFromUser(menu) != wxID_STOP) return; - if (Thread* still = Find(id); still && still->streaming) RequestCancel(*still); + if (t && t->streaming) menu.Append(wxID_STOP, "Stop"); + if (!row.branch.empty()) { + const std::string target = GitBranch(activeCwd_); + menu.Append(ID_REVIEW, "Review changes"); + menu.Append(ID_MERGE, "Merge into " + wxString::FromUTF8( + target.empty() ? std::string("the project") : target)); + menu.Append(ID_DISCARD, "Discard worktree"); + } + if (menu.GetMenuItemCount() == 0) return; + const int choice = threadList_->GetPopupMenuSelectionFromUser(menu); + if (choice == wxID_STOP) { + if (Thread* still = Find(row.id); still && still->streaming) RequestCancel(*still); + return; + } + if (choice != ID_REVIEW && choice != ID_MERGE && choice != ID_DISCARD) return; + // The outcome shows in the thread's canvas. + if (!SelectThread(row.id) || Active().worktree.empty()) return; + if (choice == ID_REVIEW) ReviewChanges(Active()); + else if (choice == ID_MERGE) MergeWorktreeThread(Active()); + else DiscardWorktreeAsking(Active()); } void ChatFrame::OnEditorToggle(wxCommandEvent&) { @@ -4272,6 +4530,10 @@ void ChatFrame::OnSend(wxCommandEvent&) { input_->Clear(); return; } + if (!t.worktree.empty() && !wxDirExists(wxString::FromUTF8(t.worktree))) { + RenderErrorBlock(t, "This thread can't run: its worktree is gone (see above)."); + return; + } input_->Clear(); std::vector images = std::move(pendingImages_); @@ -4331,9 +4593,10 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, UpdateQueueUI(); // Send becomes "Add", chip row stays visible if non-empty. } - // One running turn per folder: until the other thread there finishes, - // this one waits (StartNextWaiting starts it). - if (RunningIn(t.cwd, t)) { + // One running turn per folder (a worktree thread's checkout is a folder + // of its own): until the other thread there finishes, this one waits + // (StartNextWaiting starts it). + if (RunningIn(t.workDir, t)) { t.waiting = true; RenderWaitingNotice(t); } else { @@ -4342,14 +4605,14 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, RefreshSessionChoice(); // marks the thread as running } -ChatFrame::Thread* ChatFrame::RunningIn(const std::string& cwd, const Thread& self) { +ChatFrame::Thread* ChatFrame::RunningIn(const std::string& dir, const Thread& self) { // A thread and its subagents count as one owner of the folder (the // subagents are read-only, see IsWriteTool). auto family = [](const Thread& t) -> const std::string& { return t.parent.empty() ? t.id : t.parent; }; for (Thread& t : threads_) { - if (&t != &self && t.cwd == cwd && t.streaming && !t.waiting + if (&t != &self && t.workDir == dir && t.streaming && !t.waiting && family(t) != family(self)) return &t; } @@ -4360,10 +4623,10 @@ int ChatFrame::ModelIndexFor(const Thread& t) const { return t.parent.empty() ? currentModelIndex_ : t.turnModelIndex; } -void ChatFrame::StartNextWaiting(const std::string& cwd) { +void ChatFrame::StartNextWaiting(const std::string& dir) { for (Thread& t : threads_) { - if (!t.waiting || t.cwd != cwd) continue; - if (RunningIn(cwd, t)) return; + if (!t.waiting || t.workDir != dir) continue; + if (RunningIn(dir, t)) return; t.waiting = false; StartCompletion(t); return; @@ -4371,7 +4634,7 @@ void ChatFrame::StartNextWaiting(const std::string& cwd) { } void ChatFrame::RenderWaitingNotice(Thread& t) { - const Thread* other = RunningIn(t.cwd, t); + const Thread* other = RunningIn(t.workDir, t); if (!other) return; RenderErrorBlock(t, "Waiting for " + ThreadLabel(other->id, other->cwd, other->title) + wxString::FromUTF8(" to finish\xE2\x80\xA6")); @@ -4560,8 +4823,8 @@ void ChatFrame::DoSendActualRequest(Thread& t) { && messages[0].value("role", std::string{}) == "system") { std::string base = messages[0].value("content", std::string{}); std::string extra; - if (!t.cwd.empty()) - extra += "\n\nWorking directory: " + t.cwd; + if (!t.workDir.empty()) + extra += "\n\nWorking directory: " + t.workDir; // 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 @@ -5109,7 +5372,7 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, return m.is_object() && m.value("isSummary", false); }); ++t.gen; - t.toolWorker = std::thread([this, jobs, token, cwd = t.cwd, enableGrit, + t.toolWorker = std::thread([this, jobs, token, dir = t.workDir, folder = t.cwd, enableGrit, searchExclude = compacted ? std::string() : t.id, id = t.id, gen = t.gen]() { std::vector results; @@ -5121,8 +5384,11 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, } else if (!job.parseError.empty()) { r = std::move(job.parseError); } else { - r = DispatchTool(job.name, job.argsParsed, token.get(), - &memory_, cwd, enableGrit, searchExclude); + // History search's scope "folder" is the project folder, + // where a worktree thread's history is indexed too. + r = DispatchTool(job.name, job.argsParsed, token.get(), &memory_, + job.name == "grit_history_search" ? folder : dir, + enableGrit, searchExclude); } results.push_back({std::move(job.id), std::move(job.name), std::move(job.argsJson), std::move(r)}); @@ -5219,7 +5485,7 @@ void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { // The folder is free: a thread waiting for it goes before this thread's // own queue, which would otherwise keep the folder to itself. - StartNextWaiting(t.cwd); + StartNextWaiting(t.workDir); if (!t.parentToolId.empty()) ReportToParent(t, wasCancelledOrError); // Natural completion: auto-dispatch the next queued message so back-to- @@ -5258,6 +5524,7 @@ bool ChatFrame::HasRunningChild(const Thread& t) const { ChatFrame::Thread& ChatFrame::OpenChild(Thread& parent, const std::string& toolId, const std::string& description) { Thread& c = OpenThread(SessionStore::NewThreadId(), parent.cwd); + c.workDir = parent.workDir; c.parent = parent.id; c.parentToolId = toolId; c.title = DefaultTitle(description.empty() ? std::string("Subagent") : description); @@ -6173,6 +6440,7 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { if (!agents.empty()) append += "\n\n" + agents; if (std::string folder = FolderContext(t); !folder.empty()) append += "\n\n" + folder; + if (!t.worktree.empty()) append += "\n\n" + WorktreeNote(t.cwd, t.workDir, t.branch); const std::string promptFile = writeTemp(append); // gritcode's own stdio MCP server, in its run_project-only mode, so the @@ -6183,7 +6451,7 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { {"gritcode", { {"type", "stdio"}, {"command", wxStandardPaths::Get().GetExecutablePath().utf8_string()}, - {"args", {"--mcp-stdio", "--run-project", t.cwd}}, + {"args", {"--mcp-stdio", "--run-project", t.workDir}}, }}, }}, }; @@ -6192,7 +6460,7 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { ClaudeRunSpec spec; spec.executable = exe; - spec.cwd = t.cwd; + spec.cwd = t.workDir; spec.args = { "-p", "--input-format", "stream-json", diff --git a/src/chat_frame.h b/src/chat_frame.h index 6d6d9bf..1ea2abe 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -121,10 +122,19 @@ class ChatFrame : public wxFrame { // 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 cwd; // the folder (project) it belongs to 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 worktree thread writes in its own git checkout (worktree root, on + // `branch`), so it runs alongside the folder's other threads; both + // are empty for a thread working in the folder itself. + std::string worktree; + std::string branch; + // Where its tools and Claude Code run: `cwd`, or the same folder in + // its worktree (a subagent: its parent's). One running turn per + // workDir (see RunningIn). + std::string workDir; // 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 @@ -460,9 +470,9 @@ class ChatFrame : public wxFrame { wxTreeItemId FindTreeItemByPath(wxTreeItemId parent, const wxString& path); // Repopulate the thread dropdown: the active folder's threads, most - // recent first (running ones marked), then "New thread" and - // "Open folder…", and select the active thread. Refreshes the thread - // pane too. + // recent first (running ones marked), then "New thread", "New thread in + // worktree" (git repos only), "Project brief…" and "Open folder…", and + // select the active thread. Refreshes the thread pane too. void RefreshSessionChoice(); // ---- Thread pane (optional, left of everything) ---- @@ -470,6 +480,7 @@ class ChatFrame : public wxFrame { // active thread. Hidden unless toggled on (remembered in preferences). wxPanel* threadPane_ = nullptr; ThreadList* threadList_ = nullptr; + wxButton* newWorktreeBtn_ = nullptr; // shown when the folder is a git repo // 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 @@ -492,6 +503,9 @@ class ChatFrame : public wxFrame { void CreateNewSession(); bool SwitchToCwd(const std::string& cwd); bool NewThread(const std::string& cwd); + // NewWorktreeThread adds an empty thread of `cwd` that works in a new git + // worktree; failures show as a notice. Only offered in git repos. + bool NewWorktreeThread(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. @@ -503,6 +517,24 @@ class ChatFrame : public wxFrame { // Persist the thread's history to its session file. void Persist(Thread& t); + // ---- Worktree threads ---- + // Review shows the worktree's diff against where it branched off; Merge + // commits its uncommitted changes on its branch and merges that into the + // project's branch; Discard removes the worktree and branch after + // `confirm` agreed to what would be lost (unmerged commits, uncommitted + // changes), and the thread works in the folder from then on. Each shows + // its outcome in t's canvas and returns it; Discard returns nothing when + // not confirmed. + std::string ReviewChanges(Thread& t); + std::string MergeWorktreeThread(Thread& t); + std::optional DiscardWorktree( + Thread& t, const std::function& confirm); + // Discard, asking the user with a dialog. + void DiscardWorktreeAsking(Thread& t); + // The actions' header at the top of a worktree thread's canvas, or the + // notice that its worktree is gone. + Block WorktreeHeader(const 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 — @@ -629,10 +661,11 @@ class ChatFrame : public wxFrame { void StartTurn(Thread& t, const wxString& userText, std::vector images = {}, const std::string& hiddenInstructions = std::string()); - // The thread (other than `self`) running a turn in `cwd`, if any. - Thread* RunningIn(const std::string& cwd, const Thread& self); - // Start the first turn waiting for `cwd`, unless one is running there. - void StartNextWaiting(const std::string& cwd); + // The thread (other than `self` and its family) running a turn in work + // dir `dir`, if any. + Thread* RunningIn(const std::string& dir, const Thread& self); + // Start the first turn waiting for work dir `dir`, unless one runs there. + void StartNextWaiting(const std::string& dir); void EnqueueMessage(Thread& t, const wxString& text); void LogDebug(const std::string& line); void OpenDebugWindow(); diff --git a/src/git_worktree.cpp b/src/git_worktree.cpp new file mode 100644 index 0000000..d5abe52 --- /dev/null +++ b/src/git_worktree.cpp @@ -0,0 +1,272 @@ +#include "git_worktree.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +struct GitRun { + int code = -1; // exit status; -1 when git didn't exit normally + std::string out; + std::string err; +}; + +// Runs `git -C dir args...` (fork/exec, no shell) and collects its output. +GitRun Git(const std::string& dir, std::vector args) { + args.insert(args.begin(), {"git", "-C", dir}); + std::vector argv; + for (std::string& a : args) argv.push_back(a.data()); + argv.push_back(nullptr); + + GitRun r; + int out[2], err[2]; + if (pipe(out) < 0) return r; + if (pipe(err) < 0) { + close(out[0]); close(out[1]); + return r; + } + for (int fd : {out[0], out[1], err[0], err[1]}) fcntl(fd, F_SETFD, FD_CLOEXEC); + const int devNull = open("/dev/null", O_RDONLY | O_CLOEXEC); + + const pid_t pid = fork(); + if (pid == 0) { + if (devNull >= 0) dup2(devNull, STDIN_FILENO); + dup2(out[1], STDOUT_FILENO); + dup2(err[1], STDERR_FILENO); + execvp("git", argv.data()); + const char msg[] = "could not run git\n"; + (void)!write(STDERR_FILENO, msg, sizeof msg - 1); + _exit(127); + } + close(out[1]); + close(err[1]); + if (devNull >= 0) close(devNull); + if (pid < 0) { + close(out[0]); close(err[0]); + r.err = "could not start git"; + return r; + } + + // Drain both pipes together so neither fills up and blocks git. + pollfd fds[2] = {{out[0], POLLIN, 0}, {err[0], POLLIN, 0}}; + std::string* sinks[2] = {&r.out, &r.err}; + int openFds = 2; + char buf[65536]; + while (openFds > 0) { + if (poll(fds, 2, -1) < 0) { + if (errno == EINTR) continue; + break; + } + for (int i = 0; i < 2; ++i) { + if (fds[i].fd < 0 || !fds[i].revents) continue; + const ssize_t n = read(fds[i].fd, buf, sizeof buf); + if (n > 0) { + sinks[i]->append(buf, (size_t)n); + } else if (n == 0 || errno != EINTR) { + close(fds[i].fd); + fds[i].fd = -1; + --openFds; + } + } + } + for (const pollfd& p : fds) + if (p.fd >= 0) close(p.fd); + int status = 0; + while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} + if (WIFEXITED(status)) r.code = WEXITSTATUS(status); + return r; +} + +std::string Trim(std::string s) { + while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); + return s; +} + +// What went wrong, in git's words. +std::string Failure(const GitRun& r) { + std::string msg = Trim(r.err.empty() ? r.out : r.err); + return msg.empty() ? "git failed" : msg; +} + +std::vector Lines(const std::string& s) { + std::vector lines; + std::istringstream in(s); + for (std::string line; std::getline(in, line);) + if (!line.empty()) lines.push_back(line); + return lines; +} + +// "a, b, c and 4 more" +std::string FileList(const std::vector& files) { + constexpr size_t kShown = 5; + std::string out; + for (size_t i = 0; i < files.size() && i < kShown; ++i) + out += (i ? ", " : "") + files[i]; + if (files.size() > kShown) out += " and " + std::to_string(files.size() - kShown) + " more"; + return out; +} + +} // namespace + +std::string GitTopLevel(const std::string& dir) { + GitRun r = Git(dir, {"rev-parse", "--show-toplevel"}); + return r.code == 0 ? Trim(r.out) : std::string(); +} + +std::string GitBranch(const std::string& dir) { + GitRun r = Git(dir, {"symbolic-ref", "--quiet", "--short", "HEAD"}); + return r.code == 0 ? Trim(r.out) : std::string(); +} + +std::string AddWorktree(const std::string& project, const std::string& worktree, + const std::string& branch) { + std::error_code ec; + fs::create_directories(fs::path(worktree).parent_path(), ec); + GitRun r = Git(project, {"worktree", "add", "-b", branch, worktree, "HEAD"}); + return r.code == 0 ? std::string() : Failure(r); +} + +std::string WorktreeDir(const std::string& project, const std::string& worktree) { + const std::string top = GitTopLevel(project); + std::error_code ec; + const fs::path rel = top.empty() ? fs::path() : fs::relative(project, top, ec); + if (ec || rel.empty() || rel == "." || *rel.begin() == "..") return worktree; + return (fs::path(worktree) / rel).string(); +} + +WorktreeWork UnmergedWork(const std::string& project, const std::string& worktree, + const std::string& branch) { + WorktreeWork w; + if (fs::exists(worktree)) { + GitRun s = Git(worktree, {"status", "--porcelain"}); + if (s.code != 0) { + w.error = Failure(s); + return w; + } + w.changedFiles = (int)Lines(s.out).size(); + } + GitRun c = Git(project, {"rev-list", "--count", "HEAD.." + branch}); + if (c.code != 0) w.error = Failure(c); + else w.unmergedCommits = std::atoi(c.out.c_str()); + return w; +} + +WorktreeDiff ReviewWorktree(const std::string& project, const std::string& worktree) { + WorktreeDiff d; + if (!fs::exists(worktree)) { + d.error = "The worktree " + worktree + " no longer exists."; + return d; + } + GitRun head = Git(project, {"rev-parse", "HEAD"}); + if (head.code != 0) { + d.error = Failure(head); + return d; + } + // Where the worktree branched off the project's branch, even when that + // has moved on since (e.g. another worktree was merged). + GitRun base = Git(worktree, {"merge-base", "HEAD", Trim(head.out)}); + if (base.code != 0) { + d.error = Failure(base); + return d; + } + if (GitRun add = Git(worktree, {"add", "-A"}); add.code != 0) { + d.error = Failure(add); + return d; + } + GitRun stat = Git(worktree, {"diff", "--cached", "--no-color", "--stat", Trim(base.out)}); + GitRun diff = Git(worktree, {"diff", "--cached", "--no-color", "--no-ext-diff", + Trim(base.out)}); + if (diff.code != 0) { + d.error = Failure(diff); + return d; + } + d.stat = Trim(stat.out); + d.diff = std::move(diff.out); + return d; +} + +MergeOutcome MergeWorktree(const std::string& project, const std::string& worktree, + const std::string& branch, const std::string& commitMessage) { + const std::string target = GitBranch(project); + if (target.empty()) + return {false, "The project checkout isn't on a branch (detached HEAD). Check out " + "the branch to merge into, then merge again. Nothing was changed."}; + GitRun dirty = Git(project, {"status", "--porcelain", "--untracked-files=no"}); + if (dirty.code != 0) return {false, "Can't merge: " + Failure(dirty)}; + if (std::vector files = Lines(dirty.out); !files.empty()) { + for (std::string& f : files) f = f.substr(3); // drop the "XY " status + return {false, "The project checkout has uncommitted changes (" + FileList(files) + + "). Commit or stash them, then merge again. Nothing was changed."}; + } + if (!fs::exists(worktree)) + return {false, "The worktree " + worktree + " no longer exists. Nothing was changed."}; + + // The worktree's uncommitted work becomes a commit on its branch. + bool committed = false; + if (GitRun add = Git(worktree, {"add", "-A"}); add.code != 0) + return {false, "Can't commit the worktree's changes: " + Failure(add)}; + if (Git(worktree, {"diff", "--cached", "--quiet"}).code == 1) { + GitRun commit = Git(worktree, {"commit", "-q", "-m", commitMessage}); + if (commit.code != 0) + return {false, "Committing the worktree's changes on " + branch + " failed: " + + Failure(commit) + "\nNothing was merged."}; + committed = true; + } + const std::string committedNote = + committed ? "The worktree's uncommitted changes were committed on " + branch + + " as \"" + commitMessage + "\". " + : std::string(); + + GitRun ahead = Git(project, {"rev-list", "--count", "HEAD.." + branch}); + if (ahead.code != 0) return {false, committedNote + "Can't merge: " + Failure(ahead)}; + if (std::atoi(ahead.out.c_str()) == 0) + return {false, committedNote + "Nothing to merge: " + target + " already has " + "everything on " + branch + "."}; + + GitRun merge = Git(project, {"merge", "--no-ff", "--no-edit", "-m", + "Merge " + branch + " (Gritcode worktree thread)", branch}); + if (merge.code == 0) + return {true, committedNote + "Merged " + branch + " into " + target + "."}; + + // Refused before it started (e.g. it would overwrite untracked files): + // nothing changed. + if (Git(project, {"rev-parse", "-q", "--verify", "MERGE_HEAD"}).code != 0) + return {false, committedNote + "Merging " + branch + " into " + target + " failed: " + + Failure(merge) + "\n" + target + " is unchanged."}; + + // Stopped on conflicts: put the user's checkout back as it was. + const std::string conflicts = + FileList(Lines(Git(project, {"diff", "--name-only", "--diff-filter=U"}).out)); + const std::string what = "Merging " + branch + " into " + target + " hit conflicts" + + (conflicts.empty() ? std::string() : " in " + conflicts); + if (GitRun abort = Git(project, {"merge", "--abort"}); abort.code != 0) + return {false, committedNote + what + ", and git merge --abort failed: " + + Failure(abort) + "\nThe project checkout " + project + + " is still in the middle of the merge: resolve the conflicts " + "and commit, or run git merge --abort there."}; + return {false, committedNote + what + ". The merge was aborted; " + target + + " and the project checkout are as before. To bring the work in, " + "ask this thread to merge " + target + " into its branch and " + "resolve the conflicts in its worktree, then merge again."}; +} + +std::string RemoveWorktree(const std::string& project, const std::string& worktree, + const std::string& branch) { + GitRun r = fs::exists(worktree) + ? Git(project, {"worktree", "remove", "--force", worktree}) + : Git(project, {"worktree", "prune"}); + if (r.code != 0) return Failure(r); + if (Git(project, {"rev-parse", "-q", "--verify", "refs/heads/" + branch}).code != 0) + return {}; // already gone + r = Git(project, {"branch", "-D", branch}); + return r.code == 0 ? std::string() : Failure(r); +} diff --git a/src/git_worktree.h b/src/git_worktree.h new file mode 100644 index 0000000..7e0dfd9 --- /dev/null +++ b/src/git_worktree.h @@ -0,0 +1,61 @@ +#pragma once +#include + +// Git worktrees for worktree threads: a thread writes in its own checkout on +// branch grit/, so it never writes under the project folder's other +// threads, and the user reviews, merges or discards that branch. Plain +// functions that run git synchronously (argv, no shell). `project` is the +// thread's folder, anywhere inside the user's checkout; `worktree` is the +// root of the thread's checkout. + +// The root of the checkout holding `dir`; empty when it isn't in a git repo +// (or git can't run). +std::string GitTopLevel(const std::string& dir); + +// The branch checked out at `dir`; empty when HEAD is detached. +std::string GitBranch(const std::string& dir); + +// Creates `worktree` on a new branch `branch` from the project's HEAD (its +// committed state). Returns git's error, empty on success. +std::string AddWorktree(const std::string& project, const std::string& worktree, + const std::string& branch); + +// Where a thread of folder `project` works in `worktree`: the same subfolder +// of it as `project` is of the user's checkout. +std::string WorktreeDir(const std::string& project, const std::string& worktree); + +// What discarding the worktree would lose. +struct WorktreeWork { + int unmergedCommits = 0; // on the branch but not in the project's HEAD + int changedFiles = 0; // uncommitted in the worktree, new files included + std::string error; // git failed: the counts are unknown +}; +WorktreeWork UnmergedWork(const std::string& project, const std::string& worktree, + const std::string& branch); + +// Everything the worktree changed (committed and uncommitted, new files +// included) since it branched off the project's HEAD. Stages the worktree's +// files to see new ones; its index is the thread's, not the user's. +struct WorktreeDiff { + std::string stat; // git diff --stat + std::string diff; + std::string error; +}; +WorktreeDiff ReviewWorktree(const std::string& project, const std::string& worktree); + +// Commits the worktree's uncommitted changes on its branch as +// `commitMessage`, then merges the branch into the project's branch with a +// merge commit. Refuses when the project checkout has uncommitted changes or +// isn't on a branch; a conflicting merge is aborted. `message` says what +// happened and, when it stopped, what to do. +struct MergeOutcome { + bool merged = false; + std::string message; +}; +MergeOutcome MergeWorktree(const std::string& project, const std::string& worktree, + const std::string& branch, const std::string& commitMessage); + +// Removes the worktree (with whatever it holds; prunes it when it's gone +// already) and deletes its branch. Returns git's error, empty on success. +std::string RemoveWorktree(const std::string& project, const std::string& worktree, + const std::string& branch); diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp index ce1a61b..65600d6 100644 --- a/src/mcp_server.cpp +++ b/src/mcp_server.cpp @@ -231,7 +231,14 @@ json MCPServer::HandleRequest(const json& request) { } if (method == "newThread") { if (!cb_.newThread) return err(-32603, "not ready"); - return okResult(cb_.newThread(params.value("cwd", ""))); + return okResult(cb_.newThread(params.value("cwd", ""), + params.value("worktree", false))); + } + if (method == "worktreeAction") { + if (!cb_.worktreeAction) return err(-32603, "not ready"); + std::string action = params.value("action", ""); + if (action.empty()) return err(-32602, "missing 'action' param"); + return okResult(cb_.worktreeAction(action, params.value("confirm", false))); } if (method == "selectThread") { if (!cb_.selectThread) return err(-32603, "not ready"); diff --git a/src/mcp_server.h b/src/mcp_server.h index 779411f..a5e4d7f 100644 --- a/src/mcp_server.h +++ b/src/mcp_server.h @@ -55,15 +55,22 @@ struct MCPCallbacks { std::function newSession; // Threads. listThreads returns {threads: [{id, cwd, title, lastUsed, - // loaded, running, waiting, failed}, ...], 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. + // loaded, running, waiting, failed, worktree, branch}, ...], activeId}; + // `failed` marks a background failure not yet viewed. newThread adds an + // empty thread in `cwd` (the active folder when empty), in a new git + // worktree when `worktree`, 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 newThread; std::function selectThread; + // A worktree thread's actions: "review", "merge" or "discard"; `confirm` + // answers the question discard asks before losing work. Returns {ok, + // notice, asked?}: `asked` is that question, `ok` false when it wasn't + // confirmed or the thread has no worktree. + std::function worktreeAction; + // 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. diff --git a/src/session_store.cpp b/src/session_store.cpp index 257c722..b496584 100644 --- a/src/session_store.cpp +++ b/src/session_store.cpp @@ -130,6 +130,8 @@ void SessionStore::WriteSessionFile(const Entry& e, j["lastUsed"] = e.lastUsed; if (!e.title.empty()) j["title"] = e.title; if (!e.parent.empty()) j["parent"] = e.parent; + if (!e.worktree.empty()) j["worktree"] = e.worktree; + if (!e.branch.empty()) j["branch"] = e.branch; j["messages"] = history; std::string body = j.dump(2, ' ', false, @@ -166,6 +168,10 @@ bool SessionStore::SaveBrief(const std::string& cwd, const std::string& text) co return AtomicWrite(BriefPath(cwd), text); } +std::string SessionStore::WorktreePath(const std::string& id) const { + return root_ + "/worktrees/" + id; +} + void SessionStore::SetLastActive(const std::string& cwd, const std::string& threadId) { lastActiveCwd_ = cwd; lastActiveThread_ = threadId; @@ -194,6 +200,8 @@ void SessionStore::WriteIndex() const { j["sessions"].push_back(std::move(row)); } else { if (!e.parent.empty()) row["parent"] = e.parent; + if (!e.worktree.empty()) row["worktree"] = e.worktree; + if (!e.branch.empty()) row["branch"] = e.branch; j["threads"].push_back(std::move(row)); } } @@ -217,6 +225,8 @@ void SessionStore::ReadIndex() { ent.lastUsed = e.value("lastUsed", std::string{}); ent.title = e.value("title", std::string{}); ent.parent = e.value("parent", std::string{}); + ent.worktree = e.value("worktree", std::string{}); + ent.branch = e.value("branch", std::string{}); if (ent.cwd.empty()) return; // Backfill id if older index lacks it. if (ent.id.empty()) ent.id = IdForCwd(ent.cwd); @@ -242,7 +252,7 @@ void SessionStore::ReadIndex() { // 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. + // title, parent, worktree, branch) 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; diff --git a/src/session_store.h b/src/session_store.h index 02d7d8d..c140629 100644 --- a/src/session_store.h +++ b/src/session_store.h @@ -13,6 +13,9 @@ // 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 +// Worktree threads record their checkout and branch in optional `worktree` +// and `branch` fields (older versions ignore them); the checkouts live in +// ~/.local/share/gritcode/worktrees// // Writes are atomic (temp file then rename) so a crash mid-write can't // corrupt the index or any session file. class SessionStore { @@ -23,6 +26,10 @@ class SessionStore { 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 + // A worktree thread's own checkout (root) and its branch; both empty + // for threads working in the folder itself. + std::string worktree; + std::string branch; }; // Loads the index from disk, creating storage directories if needed. @@ -70,6 +77,10 @@ class SessionStore { std::string LoadBrief(const std::string& cwd) const; bool SaveBrief(const std::string& cwd, const std::string& text) const; + // Where worktree thread `id` gets its checkout: worktrees/ in the + // data dir, outside the user's repo. + std::string WorktreePath(const std::string& id) const; + // 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); diff --git a/src/thread_list.h b/src/thread_list.h index 0159ddb..115a77a 100644 --- a/src/thread_list.h +++ b/src/thread_list.h @@ -10,6 +10,7 @@ struct ThreadRow { wxString title; wxString detail; // the current step while running, else when last used State state = State::Done; + std::string branch; // a worktree thread's branch, else empty bool child = false; // a subagent, indented under its parent }; From 663e16603bca3be9ae424f0d317f03b8f748bb14 Mon Sep 17 00:00:00 2001 From: Luke Date: Tue, 20 Oct 2026 00:25:49 +0200 Subject: [PATCH 23/33] feat(threads): worktree threads (step 6) A thread can work in its own git worktree (grit/, under the data dir), so it writes alongside the folder's other threads: one running turn per work dir instead of per folder. Review shows the worktree's diff in the canvas, Merge commits its uncommitted changes and merges the branch with --no-ff (refused on a dirty checkout, aborted on conflicts), Discard asks before losing commits or changes. Worktree and branch are optional session fields. Smoke test: scripts/smoke/worktrees.py. Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 76 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index c0e8e8d..209bad9 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -41,9 +41,10 @@ bash `chdir`s in the forked child. The process-wide `ChdirToCwd` goes away. ## Write safety -One running turn per folder. Starting a turn while another thread in the same -folder is running queues it (shown as "waiting for "). Real parallel -writing in one folder comes later with worktrees. +One running turn per working folder. Starting a turn while another thread in +the same folder is running queues it (shown as "waiting for "). A +worktree thread's checkout is a working folder of its own (`Thread::workDir`, +see "Worktree threads"), so it runs alongside the project folder's threads. A thread and its subagents count as one owner of the folder, so a subagent never waits for its own parent. Subagents are read-only: `write_file`, @@ -79,7 +80,10 @@ under its parent in the dropdown and linked from the parent's tool block. ## Storage (no migration) Session files keep their format: `sessions/.json` with `id`, `cwd`, -`lastUsed`, `messages`, plus optional `title` and `parent`. The existing +`lastUsed`, `messages`, plus optional `title`, `parent`, and for worktree +threads `worktree` (the checkout's root) and `branch`; the index's `threads` +rows carry the same optional fields. Old versions ignore them and show a +worktree thread as an ordinary thread of its folder. 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 @@ -129,11 +133,69 @@ 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. +## Worktree threads + +A worktree thread writes in its own git checkout, so several threads can +change one project at once. "New thread in worktree" (dropdown entry and pane +button, offered only when the folder is in a git repo) runs +`git worktree add -b grit/ /worktrees/ HEAD`: +the checkout lives outside the user's repo and holds the committed state (the +creation notice says that uncommitted changes aren't in it). The thread still +belongs to its folder (`cwd`, listed there, same brief and AGENTS.md), while +its tools, bash and Claude Code run in `workDir`, the same subfolder of the +worktree as the folder is of the repo. Its subagents get its `workDir`, so the +family rule holds as before. Its system prompt adds that it works in an +isolated worktree of the project on its branch. History search's "folder" +scope stays the project folder. It is saved as soon as it is created, so its +checkout is never unlisted. + +Its canvas starts with a header (branch, "Review changes", "Merge into +", "Discard worktree"), the pane and dropdown rows show the branch, +and the pane's right-click menu offers the same actions. The git work is a +few free functions in `git_worktree.{h,cpp}` that run git synchronously with +an argv (fork/exec, no shell); they are quick, and run only on the user's +click. + +- Review: `git add -A` in the worktree (its index is the thread's), then + `git diff --cached `: + committed and uncommitted changes, new files included, as a stat notice and + a diff code block in the canvas (not in history, so the model doesn't see + it). +- Merge: refused while the thread (or a thread in the project folder) runs, + when the project checkout has uncommitted changes to tracked files, or when + it is on no branch. Otherwise the worktree's uncommitted changes are + committed on its branch with the thread's title as the message, then + `git merge --no-ff grit/` in the project checkout. A conflict is + aborted with `git merge --abort`; the notice names the files and suggests + merging the project's branch into the worktree branch in the thread + (conflicts are then resolved in its isolated checkout). Every outcome, + including the commit message used, shows as a notice; if the abort itself + fails, the notice says the repo is mid-merge and what to run. +- Why a merge commit rather than applying the diff as uncommitted changes: + the merge is refused unless the checkout is clean, so the user's own work + is never mixed in; a conflict aborts back to exactly the previous state + (`git apply --3way` would leave conflict markers among the user's files); + and the result is one commit, easy to inspect and undo + (`git reset --hard HEAD~1` or `git revert -m 1`). The worktree stays, so + the thread can go on and be merged again. +- Discard: refused while the thread runs. When the branch has commits not + in the project's HEAD, or the worktree has uncommitted changes (or git + can't tell), it asks first and names what would be lost. Then + `git worktree remove --force` (or `git worktree prune` when the checkout is + already gone) and `git branch -D`. The thread stays, as a thread of the + folder itself. +- A worktree deleted by hand: the header says so and offers Discard (which + prunes it and deletes the branch); sending a message is refused with a + notice. + +There is no "delete thread" yet; when there is, it must discard the +worktree the same way. + ## UI The session dropdown lists the current folder's threads (running ones marked, -subagents indented under their parent) plus "New thread", "Project brief…" -and "Open folder…". +subagents indented under their parent) plus "New thread", "New thread in +worktree" (git repos only), "Project brief…" and "Open folder…". The thread pane (toolbar toggle, remembered in preferences) shows the same rows at the far left: a "New thread" button, then per thread a state mark @@ -167,7 +229,7 @@ like the other panes. The agent map and the decision tray come later (see 5. Thread pane: an optional left list of the folder's threads and their child threads with state (running, waiting, failed, done). (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. + threads write in one project at once; diff and merge back. (done) 7. Project-scoped history search for the memory tool. (done) The orchestrator ideas (`orchestrator-conversation.md`) build on 2 and 5. From 5c1077a2505352fc5ffc918c6399145c551b11b9 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 12:41:57 +0100 Subject: [PATCH 24/33] threads: switch threads in the left pane, keep the dropdown per folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session dropdown lists folders again (New Session…, then folders most recent first, ● when one has a running thread). The separate thread pane and its toolbar button are gone: the existing left pane gets a Session | Threads switch, remembered in preferences, and the Threads view holds New thread, New thread in worktree, Project brief… and the thread list. Co-Authored-By: Claude Opus 5.5 --- assets/icons/threads.svg | 5 - docs/design/threads.md | 54 +++++---- scripts/smoke/thread_pane.py | 35 ++++-- src/chat_frame.cpp | 222 +++++++++++++++++++---------------- src/chat_frame.h | 38 +++--- src/preferences.cpp | 10 +- src/preferences.h | 7 +- 7 files changed, 198 insertions(+), 173 deletions(-) delete mode 100644 assets/icons/threads.svg diff --git a/assets/icons/threads.svg b/assets/icons/threads.svg deleted file mode 100644 index 74d07b6..0000000 --- a/assets/icons/threads.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/docs/design/threads.md b/docs/design/threads.md index 209bad9..2c7bf4f 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -56,7 +56,7 @@ 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 dropdown and linked from the parent's tool block. +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 @@ -106,7 +106,7 @@ 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. -"Project brief…" in the thread dropdown opens a dialog to edit it. "Generate" +"Project brief…" in the left pane's Threads view opens a dialog to edit it. "Generate" asks DeepSeek Flash (cheap and fast; the free model without a key; Claude's CLI doesn't fit one request) to write it from the top-level entries, README, AGENTS.md and thread titles. The text streams into the box; failures show in @@ -136,8 +136,8 @@ rebuild, as before; turns of sessions with no file any more stay searchable. ## Worktree threads A worktree thread writes in its own git checkout, so several threads can -change one project at once. "New thread in worktree" (dropdown entry and pane -button, offered only when the folder is in a git repo) runs +change one project at once. "New thread in worktree" (a Threads view button, +offered only when the folder is in a git repo) runs `git worktree add -b grit/ /worktrees/ HEAD`: the checkout lives outside the user's repo and holds the committed state (the creation notice says that uncommitted changes aren't in it). The thread still @@ -150,8 +150,8 @@ scope stays the project folder. It is saved as soon as it is created, so its checkout is never unlisted. Its canvas starts with a header (branch, "Review changes", "Merge into -", "Discard worktree"), the pane and dropdown rows show the branch, -and the pane's right-click menu offers the same actions. The git work is a +", "Discard worktree"), the thread list rows show the branch, +and their right-click menu offers the same actions. The git work is a few free functions in `git_worktree.{h,cpp}` that run git synchronously with an argv (fork/exec, no shell); they are quick, and run only on the user's click. @@ -193,29 +193,30 @@ worktree the same way. ## UI -The session dropdown lists the current folder's threads (running ones marked, -subagents indented under their parent) plus "New thread", "New thread in -worktree" (git repos only), "Project brief…" and "Open folder…". - -The thread pane (toolbar toggle, remembered in preferences) shows the same -rows at the far left: a "New thread" button, then per thread a state mark -(● running, ○ waiting for the folder, ⚠ failed until viewed) with the title, -and a dim line below: while running the current step (the tool it runs, -"writing", "thinking", "waiting for "), after a failure its notice, -otherwise when it was last used. Both are built from one row list -(`FolderRows`), so they always agree; 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 pane only while a thread of -the folder runs. Clicking a row selects the thread, right-click on a running -one offers "Stop". The pane is a fixed-width panel beside the splitters (no -sash): opening it grows the window by its width, or shares a tiled window, -like the other panes. The agent map and the decision tray come later (see +The session dropdown stays at the folder level, as in 0.5.x: "New Session…" +(pick a folder), then the folders, most recent first, a ● marking one with a +running thread. Picking a folder opens its most recently used thread. + +Threads are switched in the existing left pane (hamburger toggle). A +Session | Threads switch on top picks its view, remembered in preferences: +Session is the 0.5.x session reference, unchanged; Threads has "New thread", +"New thread in worktree" (git repos only) and "Project brief…" buttons above +the folder's threads, per thread a state mark (● running, ○ waiting for the +folder, ⚠ 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", "waiting for "), 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 offers "Stop" on a +running one and the worktree actions on a worktree thread. The agent map and the decision tray come later (see `orchestrator-conversation.md`). ## Steps 1. Threads: struct, multi-thread runtime, store, per-thread cwd, write queue, - dropdown. (done) + 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 @@ -226,8 +227,9 @@ like the other panes. The agent map and the decision tray come later (see 4. Project brief: a short per-folder brief (written or generated) and a list of the folder's other threads in every thread's system prompt. (done) -5. Thread pane: an optional left list of the folder's threads and their child - threads with state (running, waiting, failed, done). (done) +5. Thread list: a Threads view in the left pane listing the folder's threads + and their child threads with state (running, waiting, 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. (done) 7. Project-scoped history search for the memory tool. (done) diff --git a/scripts/smoke/thread_pane.py b/scripts/smoke/thread_pane.py index b0d5323..ba36361 100644 --- a/scripts/smoke/thread_pane.py +++ b/scripts/smoke/thread_pane.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """ -Smoke test for the thread pane: its rows, toggle and remembered state. +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 @@ -68,10 +69,6 @@ def day(iso): "title": "Read the theme code", "parent": "00000000000000aa"}], "lastActiveCwd": P}, f) -# The pane was left open last time. -os.makedirs(os.path.dirname(CONF)) -with open(CONF, "w") as f: - f.write("[UI]\nShowThreadPane=1\n") env = dict(os.environ, GRIT_TEST_DATA=DATA) pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}"], @@ -85,9 +82,17 @@ def day(iso): 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 remembered pane is open at startup") + 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"], @@ -122,18 +127,22 @@ def day(iso): detail = rows(g)[a]["detail"] check(":" in detail, f"a stopped row shows its time again ({detail})") - print("-- toggle") + 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["threads"] == 0, "toggling hides the pane") - check(after["chat"] == before["chat"], "the chat pane keeps its width") - check(after["window"] < before["window"], "the window shrinks") - check("ShowThreadPane=0" in open(CONF).read(), "the closed pane is remembered") + check(after["import"] == 0 and after["chat"] == before["chat"] + and after["window"] < before["window"], "closing keeps the chat width") g._call("togglePanel", {"panel": "threads"}) - time.sleep(0.5) - check(panes(g)["threads"] > 0 and len(rows(g)) >= 5, "toggling shows it again, filled") print("-- another folder") q = g.switch_session(Q)["threadId"] diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index b7000e5..a11fff1 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -85,7 +85,6 @@ constexpr int ID_EDITOR_RELOAD = wxID_HIGHEST + 23; constexpr int ID_EDITOR_CLOSE = wxID_HIGHEST + 24; constexpr int ID_EDITOR_SHOW_IN_FILES = wxID_HIGHEST + 25; constexpr int ID_MODEL_REFRESH = wxID_HIGHEST + 26; -constexpr int ID_THREADS = wxID_HIGHEST + 29; // Side-panel widths (pixels). The import pane lives in the outer splitter and // the editor pane lives in the inner (chat | editor) splitter, so the window @@ -97,7 +96,6 @@ constexpr int kEditorPaneWidth = kFileTreeWidth + kEditorTextWidth; // whole r 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. -constexpr int kThreadPaneWidth = 240; // Chat-pane widths below which toolbar items hide, least important first. constexpr int kToolbarLabelsW = 640; // "Thread:" / "Model:" captions constexpr int kToolbarSettingsW = 500; // settings (and debug Log) button @@ -1184,14 +1182,9 @@ 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)")); - wxBitmapBundle bbThreads = LoadThemedSvgIcon("threads.svg", kIconSize, accent); - threadsBtn_ = new wxBitmapButton(panel, ID_THREADS, bbThreads, - wxDefaultPosition, kBtnSize, wxBORDER_NONE); - threadsBtn_->SetToolTip(wxString::FromUTF8("Toggle thread list")); - - sessionLabel_ = new wxStaticText(panel, wxID_ANY, "Thread:"); + sessionLabel_ = new wxStaticText(panel, wxID_ANY, "Session:"); sessionChoice_ = new wxChoice(panel, ID_SESSION); sessionChoice_->SetMinSize(FromDIP(wxSize(160, -1))); modelLabel_ = new wxStaticText(panel, wxID_ANY, "Model:"); @@ -1222,7 +1215,6 @@ ChatFrame::ChatFrame() wxBORDER_NONE); playBtn_->SetToolTip(wxString::FromUTF8("Build and run project")); - toolbarRow->Add(threadsBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 2); toolbarRow->Add(hamburgerBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); toolbarRow->Add(sessionLabel_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); // Session and model both get a small proportion so they share resize delta; @@ -1362,11 +1354,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, @@ -1379,19 +1373,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); @@ -1403,7 +1397,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; @@ -1427,36 +1421,22 @@ ChatFrame::ChatFrame() importSizer->Add(importEmptyView_, 1, wxEXPAND); importSizer->Add(importCanvas_, 1, wxEXPAND); importSizer->Add(bottomBox, 0, wxEXPAND); - auto* importRoot = new wxBoxSizer(wxVERTICAL); - importRoot->Add(importSizer, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(kEdgePad)); - importPanel_->SetSizer(importRoot); - importPanel_->Hide(); // hidden until split - - // Click on import canvas copies prompt to main input. - importCanvas_->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) { - wxPoint logical = importCanvas_->CalcUnscrolledPosition( - e.GetPosition()); - BlockPos pos = importCanvas_->HitTestPublic(logical.x, logical.y); - if (!pos.IsValid()) { e.Skip(); return; } - const auto& blocks = importCanvas_->Blocks(); - if (pos.block < 0 || pos.block >= (int)blocks.size()) { e.Skip(); return; } - if (blocks[pos.block].type == BlockType::UserPrompt) { - input_->SetValue(blocks[pos.block].rawText); - } - e.Skip(); - }); + loadView_->SetSizer(importSizer); - // Thread pane — left of the splitters, hidden unless toggled on. - threadPane_ = new wxPanel(this); + threadsView_ = new wxPanel(importPanel_); { - auto* newBtn = new wxButton(threadPane_, wxID_ANY, "New thread"); + auto* newBtn = new wxButton(threadsView_, wxID_ANY, "New thread"); newBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { NewThread(activeCwd_); }); - newWorktreeBtn_ = new wxButton(threadPane_, wxID_ANY, "New thread in worktree"); + newWorktreeBtn_ = new wxButton(threadsView_, wxID_ANY, "New thread in worktree"); newWorktreeBtn_->SetToolTip("A thread that works in its own git worktree, so it can " "write alongside the folder's other threads"); newWorktreeBtn_->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { NewWorktreeThread(activeCwd_); }); - threadList_ = new ThreadList(threadPane_); + auto* briefBtn = new wxButton(threadsView_, wxID_ANY, + wxString::FromUTF8("Project brief\xE2\x80\xA6")); + briefBtn->SetToolTip("What every new thread of this folder is told about the project"); + briefBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EditProjectBrief(); }); + 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; @@ -1468,11 +1448,43 @@ ChatFrame::ChatFrame() auto* sizer = new wxBoxSizer(wxVERTICAL); sizer->Add(newBtn, 0, wxEXPAND | wxBOTTOM, 4); sizer->Add(newWorktreeBtn_, 0, wxEXPAND | wxBOTTOM, 4); + sizer->Add(briefBtn, 0, wxEXPAND | wxBOTTOM, 4); sizer->Add(threadList_, 1, wxEXPAND); - threadPane_->SetSizer(sizer); - threadPane_->SetMinSize(wxSize(kThreadPaneWidth, -1)); - threadPane_->Show(Preferences::GetShowThreadPane()); + threadsView_->SetSizer(sizer); } + + sessionTab_ = new wxToggleButton(importPanel_, wxID_ANY, "Session"); + sessionTab_->SetToolTip("A saved session to copy prompts from"); + threadsTab_ = new wxToggleButton(importPanel_, wxID_ANY, "Threads"); + threadsTab_->SetToolTip("This folder's threads"); + sessionTab_->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { ShowLeftView(false); }); + threadsTab_->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { ShowLeftView(true); }); + auto* tabs = new wxBoxSizer(wxHORIZONTAL); + tabs->Add(sessionTab_, 1); + tabs->Add(threadsTab_, 1); + + auto* importRoot = new wxBoxSizer(wxVERTICAL); + importRoot->Add(tabs, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, FromDIP(kEdgePad)); + importRoot->Add(loadView_, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(kEdgePad)); + importRoot->Add(threadsView_, 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(kEdgePad)); + importPanel_->SetSizer(importRoot); + ShowLeftView(Preferences::GetLeftPaneThreads()); + importPanel_->Hide(); // hidden until split + + // Click on import canvas copies prompt to main input. + importCanvas_->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& e) { + wxPoint logical = importCanvas_->CalcUnscrolledPosition( + e.GetPosition()); + BlockPos pos = importCanvas_->HitTestPublic(logical.x, logical.y); + if (!pos.IsValid()) { e.Skip(); return; } + const auto& blocks = importCanvas_->Blocks(); + if (pos.block < 0 || pos.block >= (int)blocks.size()) { e.Skip(); return; } + if (blocks[pos.block].type == BlockType::UserPrompt) { + input_->SetValue(blocks[pos.block].rawText); + } + e.Skip(); + }); + threadPaneTimer_.SetOwner(this); Bind(wxEVT_TIMER, [this](wxTimerEvent&) { UpdateThreadPane(FolderRows()); }, threadPaneTimer_.GetId()); @@ -1484,13 +1496,10 @@ ChatFrame::ChatFrame() &ChatFrame::OnInnerSashChanging, this); auto* frameSizer = new wxBoxSizer(wxHORIZONTAL); - frameSizer->Add(threadPane_, 0, wxEXPAND | wxTOP | wxBOTTOM | wxLEFT, FromDIP(kEdgePad)); // Top/bottom padding wraps the whole splitter so its sashes are inset // from the window edges instead of running edge-to-edge. frameSizer->Add(splitter_, 1, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(kEdgePad)); SetSizer(frameSizer); - // A remembered open pane widens the initial window by its width. - SetSize(GetSize().x + ThreadPaneW(), GetSize().y); { PERF_SCOPE("SessionStore::Init"); store_.Init(); } // Open the FTS5 memory index. Failure (e.g. read-only home) is silent; @@ -1545,7 +1554,6 @@ ChatFrame::ChatFrame() Bind(wxEVT_BUTTON, &ChatFrame::OnExport, this, ID_EXPORT); Bind(wxEVT_BUTTON, &ChatFrame::OnHamburger, this, ID_HAMBURGER); Bind(wxEVT_BUTTON, &ChatFrame::OnEditorToggle, this, ID_EDITOR); - Bind(wxEVT_BUTTON, &ChatFrame::OnThreadPaneToggle, this, ID_THREADS); fileTree_->Bind(wxEVT_TREE_ITEM_EXPANDING, &ChatFrame::OnEditorTreeExpanding, this); fileTree_->Bind(wxEVT_TREE_ITEM_COLLAPSED, &ChatFrame::OnEditorTreeCollapsed, this); fileTree_->Bind(wxEVT_TREE_SEL_CHANGED, &ChatFrame::OnEditorTreeSelect, this); @@ -1922,8 +1930,12 @@ ChatFrame::ChatFrame() 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}}); + 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}, - {"threads", threadPane_->IsShown() ? threadPane_->GetSize().x : 0}, + {"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}, @@ -1932,9 +1944,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 if (panel == "threads") OnThreadPaneToggle(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(); }); }; @@ -1962,7 +1984,7 @@ ChatFrame::ChatFrame() SetDropTarget(new FrameFileDropTarget(this)); input_->SetFocus(); - SetMinClientSize(wxSize(kMainMinClientW + ThreadPaneW(), 400)); + SetMinClientSize(wxSize(kMainMinClientW, 400)); #ifdef __WXOSX__ // Tahoe look: native styling only, the layout above stays shared. @@ -2375,7 +2397,6 @@ void ChatFrame::ReloadToolbarIcons() { exportBtn_->SetBitmap(LoadThemedSvgIcon("export.svg", kIconSize, accent)); hamburgerBtn_->SetBitmap(LoadThemedSvgIcon("hamburger.svg", kIconSize, accent)); editorBtn_->SetBitmap(LoadThemedSvgIcon("editor.svg", kIconSize, accent)); - threadsBtn_->SetBitmap(LoadThemedSvgIcon("threads.svg", kIconSize, accent)); } ChatFrame::Thread* ChatFrame::Find(const std::string& id) { @@ -2495,33 +2516,34 @@ wxString ChatFrame::CurrentStep(Thread& t) { void ChatFrame::RefreshSessionChoice() { PERF_SCOPE("RefreshSessionChoice"); - std::vector rows = FolderRows(); + // 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(); - threadRowIds_.clear(); - for (const ThreadRow& r : rows) { - wxString label = r.title; - if (r.state == ThreadRow::State::Running || r.state == ThreadRow::State::Waiting) - label = wxString::FromUTF8("\xE2\x97\x8F ") + label; // "● " - else if (r.state == ThreadRow::State::Failed) - label = wxString::FromUTF8("\xE2\x9A\xA0 ") + label; // "⚠ " - if (r.child) label = wxString::FromUTF8(" \xE2\x86\xB3 ") + label; // "↳ " - if (!r.branch.empty()) label += wxString::FromUTF8(" [" + r.branch + "]"); - sessionChoice_->Append(label); - threadRowIds_.push_back(r.id); - } - sessionChoice_->Append("New thread"); + sessionChoice_->Append(wxString::FromUTF8("New Session\xE2\x80\xA6")); + for (const std::string& cwd : sessionCwds_) { + const bool running = std::ranges::any_of(threads_, [&](const Thread& t) { + return t.cwd == cwd && t.streaming; + }); + sessionChoice_->Append((running ? wxString::FromUTF8("\xE2\x97\x8F ") : wxString()) + + 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_)); const bool git = !FindGitRoot(activeCwd_).empty(); - if (git) sessionChoice_->Append("New thread in worktree"); if (newWorktreeBtn_->IsShown() != git) { newWorktreeBtn_->Show(git); - threadPane_->Layout(); + threadsView_->Layout(); } - sessionChoice_->Append(wxString::FromUTF8("Project brief\xE2\x80\xA6")); - sessionChoice_->Append(wxString::FromUTF8("Open folder\xE2\x80\xA6")); - auto active = std::ranges::find(threadRowIds_, Active().id); - sessionChoice_->SetSelection((int)(active - threadRowIds_.begin())); - sessionChoice_->SetToolTip(wxString::FromUTF8(activeCwd_)); - if (threadPane_->IsShown()) UpdateThreadPane(std::move(rows)); + if (ThreadsShown()) UpdateThreadPane(FolderRows()); } void ChatFrame::UpdateThreadPane(std::vector rows) { @@ -2537,20 +2559,12 @@ void ChatFrame::UpdateThreadPane(std::vector rows) { void ChatFrame::OnSessionChoice(wxCommandEvent& evt) { const int sel = evt.GetSelection(); - const int threadRows = (int)threadRowIds_.size(); - const std::string id = sel >= 0 && sel < threadRows ? threadRowIds_[sel] - : std::string(); - // The rows after the threads: New thread, New thread in worktree (git - // repos only), Project brief, Open folder. - int action = sel - threadRows; - if (action >= 1 && FindGitRoot(activeCwd_).empty()) ++action; - // Snap back to the active thread; a successful switch reselects. + 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 (!id.empty()) SelectThread(id); - else if (action == 0) NewThread(activeCwd_); - else if (action == 1) NewWorktreeThread(activeCwd_); - else if (action == 2) EditProjectBrief(); - else if (action == 3) CreateNewSession(); + if (sel == 0) CreateNewSession(); + else if (!cwd.empty() && cwd != activeCwd_) SwitchToCwd(cwd); } void ChatFrame::CreateNewSession() { @@ -3310,24 +3324,24 @@ void ChatFrame::OnHamburger(wxCommandEvent&) { importPanel_->Show(); splitter_->SplitVertically(importPanel_, innerSplitter_, kImportPaneWidth); } + if (ThreadsShown()) UpdateThreadPane(FolderRows()); + else threadPaneTimer_.Stop(); SyncPanelSizing(centerW, tiled); } -void ChatFrame::OnThreadPaneToggle(wxCommandEvent&) { - // Like the import pane: the chat pane keeps its width, the window grows - // or shrinks (unless tiled). - const bool tiled = hyprland::IsTiled().value_or(false); - int centerW = mainPanel_->GetSize().x; - const bool show = !threadPane_->IsShown(); - threadPane_->Show(show); - Preferences::SetShowThreadPane(show); - if (show) UpdateThreadPane(FolderRows()); +void ChatFrame::ShowLeftView(bool threads) { + sessionTab_->SetValue(!threads); + threadsTab_->SetValue(threads); + loadView_->Show(!threads); + threadsView_->Show(threads); + importPanel_->Layout(); + Preferences::SetLeftPaneThreads(threads); + if (ThreadsShown()) UpdateThreadPane(FolderRows()); else threadPaneTimer_.Stop(); - SyncPanelSizing(centerW, tiled); } -int ChatFrame::ThreadPaneW() const { - return threadPane_->IsShown() ? kThreadPaneWidth + FromDIP(kEdgePad) : 0; +bool ChatFrame::ThreadsShown() const { + return splitter_->IsSplit() && threadsView_->IsShown(); } void ChatFrame::OnThreadListContextMenu(wxContextMenuEvent& e) { @@ -3392,7 +3406,7 @@ void ChatFrame::OnEditorToggle(wxCommandEvent&) { int importW = splitter_->IsSplit() ? kImportPaneWidth + splitter_->GetSashSize() : 0; int editorW = editorPaneW_ + innerSplitter_->GetSashSize(); - SetClientSize(wxSize(ThreadPaneW() + centerW + importW + editorW, + SetClientSize(wxSize(centerW + importW + editorW, GetClientSize().y)); Layout(); splitter_->UpdateSize(); @@ -3423,9 +3437,8 @@ void ChatFrame::SyncPanelSizing(int centerW, bool tiled) { int editorMinW = innerSplitter_->IsSplit() ? innerSplitter_->GetMinimumPaneSize() + innerSplitter_->GetSashSize() : 0; - const int threadW = ThreadPaneW(); - SetMinClientSize(wxSize(kMainMinClientW + threadW + importMinW + editorMinW, 400)); - SetClientSize(wxSize(threadW + centerW + importW + editorW, GetClientSize().y)); + SetMinClientSize(wxSize(kMainMinClientW + importMinW + editorMinW, 400)); + SetClientSize(wxSize(centerW + importW + editorW, GetClientSize().y)); Layout(); splitter_->UpdateSize(); innerSplitter_->UpdateSize(); @@ -3439,7 +3452,7 @@ void ChatFrame::LayoutTiledPanes() { ? splitter_->GetMinimumPaneSize() + splitter_->GetSashSize() : 0; int editorMinW = innerSplitter_->IsSplit() ? innerSplitter_->GetMinimumPaneSize() + innerSplitter_->GetSashSize() : 0; - int minW = kMainMinClientW + ThreadPaneW() + importMinW + editorMinW; + int minW = kMainMinClientW + importMinW + editorMinW; SetMinClientSize(wxSize(std::min(minW, GetClientSize().x), 400)); Layout(); splitter_->UpdateSize(); @@ -4501,7 +4514,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()) { diff --git a/src/chat_frame.h b/src/chat_frame.h index 1ea2abe..e163b71 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -86,7 +87,7 @@ class ChatFrame : public wxFrame { // dropdowns and a gear button on the right. wxChoice* sessionChoice_ = nullptr; wxChoice* modelChoice_ = nullptr; - // "Thread:" / "Model:" captions. These, the settings/export/debug + // "Session:" / "Model:" captions. These, the settings/export/debug // buttons and the model dropdown hide as the chat pane narrows (see // UpdateToolbarFit). wxStaticText* sessionLabel_ = nullptr; @@ -98,7 +99,6 @@ class ChatFrame : public wxFrame { wxBitmapButton* exportBtn_ = nullptr; wxBitmapButton* hamburgerBtn_ = nullptr; wxBitmapButton* editorBtn_ = nullptr; - wxBitmapButton* threadsBtn_ = nullptr; // Streaming tool_call accumulator. Each delta carries an `index` field that // identifies which call the (id/name/arguments) fragments belong to. @@ -296,10 +296,10 @@ class ChatFrame : public wxFrame { wxString ThreadLabelOf(const std::string& id); // On-disk persistence: one session file per thread. The dropdown lists - // the active folder's threads (rows map to ids in threadRowIds_), then - // "New thread" and "Open folder…". + // "New Session…" and then the folders (rows map to sessionCwds_); a + // folder's threads are switched in the left pane. SessionStore store_; - std::vector threadRowIds_; + std::vector sessionCwds_; // Folder shown in the editor and file tree: the active thread's cwd. std::string activeCwd_; @@ -469,31 +469,35 @@ class ChatFrame : public wxFrame { void OnEditorShowInFiles(wxCommandEvent& e); wxTreeItemId FindTreeItemByPath(wxTreeItemId parent, const wxString& path); - // Repopulate the thread dropdown: the active folder's threads, most - // recent first (running ones marked), then "New thread", "New thread in - // worktree" (git repos only), "Project brief…" and "Open folder…", and - // select the active thread. Refreshes the thread pane too. + // 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(); - // ---- Thread pane (optional, left of everything) ---- - // The active folder's threads with their state; the selected row is the - // active thread. Hidden unless toggled on (remembered in preferences). - wxPanel* threadPane_ = nullptr; + // ---- Left pane threads view ---- + // The left pane (hamburger) switches between the session reference + // ("Session") and the active folder's threads ("Threads"); the mode is + // remembered in preferences. + wxToggleButton* sessionTab_ = nullptr; + wxToggleButton* threadsTab_ = nullptr; + wxPanel* loadView_ = nullptr; // session reference + wxPanel* threadsView_ = nullptr; // thread list ThreadList* threadList_ = nullptr; wxButton* newWorktreeBtn_ = nullptr; // shown when the folder is a git repo // 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; shared by the dropdown and the pane. + // 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); - void OnThreadPaneToggle(wxCommandEvent&); + // Picks the left pane's view; remembered for next time. + void ShowLeftView(bool threads); + // Whether the thread list is on screen (pane open, Threads view). + bool ThreadsShown() const; void OnThreadListContextMenu(wxContextMenuEvent& e); - // Width the thread pane takes from the window (0 when hidden). - int ThreadPaneW() const; // CreateNewSession pops a directory dialog and switches to that folder. // SwitchToCwd opens the most recently used thread of `cwd` (loading it, diff --git a/src/preferences.cpp b/src/preferences.cpp index 76a020e..5c36698 100644 --- a/src/preferences.cpp +++ b/src/preferences.cpp @@ -21,7 +21,7 @@ const wxString kUsername = "api_key"; const char* kModelIndexKey = "/UI/LastModelIndex"; const char* kModelExplicitKey = "/UI/ModelExplicit"; const char* kEnableGritKey = "/UI/EnableGritHistory"; -const char* kThreadPaneKey = "/UI/ShowThreadPane"; +const char* kLeftThreadsKey = "/UI/LeftPaneThreads"; const char* kReasoningEffortKey = "/DeepSeek/ReasoningEffort"; const char* kClaudeEffortKey = "/Claude/Effort"; @@ -124,15 +124,15 @@ bool Preferences::GetEnableGritHistory() { return v; } -bool Preferences::GetShowThreadPane() { +bool Preferences::GetLeftPaneThreads() { bool v = false; - wxConfigBase::Get()->Read(kThreadPaneKey, &v, false); + wxConfigBase::Get()->Read(kLeftThreadsKey, &v, false); return v; } -void Preferences::SetShowThreadPane(bool show) { +void Preferences::SetLeftPaneThreads(bool threads) { auto* cfg = wxConfigBase::Get(); - cfg->Write(kThreadPaneKey, show); + cfg->Write(kLeftThreadsKey, threads); cfg->Flush(); } diff --git a/src/preferences.h b/src/preferences.h index 0db1cb8..eb43f7d 100644 --- a/src/preferences.h +++ b/src/preferences.h @@ -40,9 +40,10 @@ class Preferences { static bool GetEnableGritHistory(); static void SetEnableGritHistory(bool enabled); - // Whether the thread pane is open. Defaults to false. - static bool GetShowThreadPane(); - static void SetShowThreadPane(bool show); + // 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". From c4bde5c399d6f0c198783d7585b9de97ae0e4f30 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 13:01:01 +0100 Subject: [PATCH 25/33] threads: left pane icon toolbar, automatic project brief, worktrees out for now The left pane's Session | Threads switch becomes a bottom toolbar like the main pane's: a session icon and a threads icon (the current view in the text colour) and "+" for a new thread in the Threads view. The project brief is no longer edited by hand: the first new thread in a folder without one has it written in the background (DeepSeek Flash, the free model without a key), and later new threads reuse it. The new thread says it is being written and by which model, then shows the brief it starts with; a failure shows the model's error and the next new thread retries. Worktree threads are reverted (merge d48a624) until they are wanted again. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 1 - assets/icons/session.svg | 4 + assets/icons/threads.svg | 8 + docs/design/threads.md | 129 +++------- scripts/grit.py | 7 +- scripts/smoke/brief.py | 77 ++++-- scripts/smoke/fake_model.py | 4 + scripts/smoke/thread_pane.py | 7 +- scripts/smoke/worktrees.py | 226 ---------------- src/chat_frame.cpp | 483 ++++++++++------------------------- src/chat_frame.h | 76 +++--- src/git_worktree.cpp | 272 -------------------- src/git_worktree.h | 61 ----- src/mcp_server.cpp | 9 +- src/mcp_server.h | 19 +- src/project_brief.cpp | 108 ++------ src/project_brief.h | 26 +- src/session_store.cpp | 12 +- src/session_store.h | 11 - src/thread_list.h | 1 - 20 files changed, 322 insertions(+), 1219 deletions(-) create mode 100644 assets/icons/session.svg create mode 100644 assets/icons/threads.svg delete mode 100644 scripts/smoke/worktrees.py delete mode 100644 src/git_worktree.cpp delete mode 100644 src/git_worktree.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9f06613..4355d92 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -253,7 +253,6 @@ set(SRCS src/preferences.cpp src/settings_dialog.cpp src/project_brief.cpp - src/git_worktree.cpp src/streaming_web_request.cpp src/run_config_store.cpp src/thread_list.cpp 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/threads.md b/docs/design/threads.md index 2c7bf4f..ffdddf1 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -41,10 +41,9 @@ bash `chdir`s in the forked child. The process-wide `ChdirToCwd` goes away. ## Write safety -One running turn per working folder. Starting a turn while another thread in -the same folder is running queues it (shown as "waiting for "). A -worktree thread's checkout is a working folder of its own (`Thread::workDir`, -see "Worktree threads"), so it runs alongside the project folder's threads. +One running turn per folder. Starting a turn while another thread in the same +folder is running queues it (shown as "waiting for "). Real parallel +writing in one folder comes later with worktrees. A thread and its subagents count as one owner of the folder, so a subagent never waits for its own parent. Subagents are read-only: `write_file`, @@ -80,10 +79,7 @@ under its parent in the thread list and linked from the parent's tool block. ## Storage (no migration) Session files keep their format: `sessions/.json` with `id`, `cwd`, -`lastUsed`, `messages`, plus optional `title`, `parent`, and for worktree -threads `worktree` (the checkout's root) and `branch`; the index's `threads` -rows carry the same optional fields. Old versions ignore them and show a -worktree thread as an ordinary thread of its folder. The existing +`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 @@ -97,8 +93,9 @@ 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, when AGENTS.md changes and when the -brief is saved; Claude turns get the same text with each run. +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 @@ -106,13 +103,16 @@ 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. -"Project brief…" in the left pane's Threads view opens a dialog to edit it. "Generate" -asks DeepSeek Flash (cheap and fast; the free model without a key; Claude's -CLI doesn't fit one request) to write it from the top-level entries, README, -AGENTS.md and thread titles. The text streams into the box; failures show in -red and restore the previous text. Nothing is generated unasked: a new thread -in a folder with other threads and no brief only shows a one-line hint -linking to the dialog. +The brief is written automatically, once: when the user starts a new thread +in a folder that has none, DeepSeek Flash (cheap and fast; the free model +without a key; Claude's CLI doesn't fit one request) writes it 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. +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 @@ -133,84 +133,26 @@ 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. -## Worktree threads - -A worktree thread writes in its own git checkout, so several threads can -change one project at once. "New thread in worktree" (a Threads view button, -offered only when the folder is in a git repo) runs -`git worktree add -b grit/ /worktrees/ HEAD`: -the checkout lives outside the user's repo and holds the committed state (the -creation notice says that uncommitted changes aren't in it). The thread still -belongs to its folder (`cwd`, listed there, same brief and AGENTS.md), while -its tools, bash and Claude Code run in `workDir`, the same subfolder of the -worktree as the folder is of the repo. Its subagents get its `workDir`, so the -family rule holds as before. Its system prompt adds that it works in an -isolated worktree of the project on its branch. History search's "folder" -scope stays the project folder. It is saved as soon as it is created, so its -checkout is never unlisted. - -Its canvas starts with a header (branch, "Review changes", "Merge into -", "Discard worktree"), the thread list rows show the branch, -and their right-click menu offers the same actions. The git work is a -few free functions in `git_worktree.{h,cpp}` that run git synchronously with -an argv (fork/exec, no shell); they are quick, and run only on the user's -click. - -- Review: `git add -A` in the worktree (its index is the thread's), then - `git diff --cached `: - committed and uncommitted changes, new files included, as a stat notice and - a diff code block in the canvas (not in history, so the model doesn't see - it). -- Merge: refused while the thread (or a thread in the project folder) runs, - when the project checkout has uncommitted changes to tracked files, or when - it is on no branch. Otherwise the worktree's uncommitted changes are - committed on its branch with the thread's title as the message, then - `git merge --no-ff grit/` in the project checkout. A conflict is - aborted with `git merge --abort`; the notice names the files and suggests - merging the project's branch into the worktree branch in the thread - (conflicts are then resolved in its isolated checkout). Every outcome, - including the commit message used, shows as a notice; if the abort itself - fails, the notice says the repo is mid-merge and what to run. -- Why a merge commit rather than applying the diff as uncommitted changes: - the merge is refused unless the checkout is clean, so the user's own work - is never mixed in; a conflict aborts back to exactly the previous state - (`git apply --3way` would leave conflict markers among the user's files); - and the result is one commit, easy to inspect and undo - (`git reset --hard HEAD~1` or `git revert -m 1`). The worktree stays, so - the thread can go on and be merged again. -- Discard: refused while the thread runs. When the branch has commits not - in the project's HEAD, or the worktree has uncommitted changes (or git - can't tell), it asks first and names what would be lost. Then - `git worktree remove --force` (or `git worktree prune` when the checkout is - already gone) and `git branch -D`. The thread stays, as a thread of the - folder itself. -- A worktree deleted by hand: the header says so and offers Discard (which - prunes it and deletes the branch); sending a message is refused with a - notice. - -There is no "delete thread" yet; when there is, it must discard the -worktree the same way. - ## 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, a ● marking one with a running thread. Picking a folder opens its most recently used thread. -Threads are switched in the existing left pane (hamburger toggle). A -Session | Threads switch on top picks its view, remembered in preferences: -Session is the 0.5.x session reference, unchanged; Threads has "New thread", -"New thread in worktree" (git repos only) and "Project brief…" buttons above -the folder's threads, per thread a state mark (● running, ○ waiting for the -folder, ⚠ 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", "waiting for "), 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 offers "Stop" on a -running one and the worktree actions on a worktree thread. The agent map and the decision tray come later (see +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, ○ waiting for the folder, ⚠ 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", "waiting for +"), 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 agent map and the decision tray come later (see `orchestrator-conversation.md`). ## Steps @@ -224,14 +166,15 @@ running one and the worktree actions on a worktree thread. The agent map and the 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 or generated) and a - list of the folder's other threads in every thread's system prompt. - (done) +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, waiting, 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. (done) + threads write in one project at once; diff and merge back. Built, then + taken out for now (merge d48a624, 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 977584c..4ee09e3 100644 --- a/scripts/grit.py +++ b/scripts/grit.py @@ -95,11 +95,8 @@ def new_session(self): def list_threads(self): return self._call("listThreads") - def new_thread(self, cwd="", worktree=False): - return self._call("newThread", {"cwd": cwd, "worktree": worktree}) - - def worktree_action(self, action, confirm=False): - return self._call("worktreeAction", {"action": action, "confirm": confirm}) + def new_thread(self, cwd=""): + return self._call("newThread", {"cwd": cwd}) def select_thread(self, thread_id): return self._call("selectThread", {"id": thread_id}) diff --git a/scripts/smoke/brief.py b/scripts/smoke/brief.py index 581aa9a..1afd388 100644 --- a/scripts/smoke/brief.py +++ b/scripts/smoke/brief.py @@ -1,13 +1,15 @@ #!/usr/bin/env python3 """ -Smoke test for the project brief: what goes into a thread's system prompt. +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). No model turns: the folder's threads are seeded -into the index before launch. +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. """ import json @@ -22,6 +24,7 @@ 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") @@ -41,8 +44,17 @@ def system_prompt(g, thread_id): return g.conversation()[0]["content"] -def hint_shown(g): - return any("A project brief tells" in b["preview"] for b in g.blocks()) +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") @@ -50,6 +62,11 @@ def hint_shown(g): 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 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")) @@ -60,9 +77,11 @@ def hint_shown(g): "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}"], +pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", + f"GRIT_FREE_MODEL_URL={serve()}"], env=env, stderr=subprocess.DEVNULL)) try: # Wait for this instance's own port (Grit would also try the next ones). @@ -75,6 +94,7 @@ def hint_shown(g): 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") @@ -82,27 +102,42 @@ def hint_shown(g): 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") - fresh = g.new_thread(P)["threadId"] - check(hint_shown(g), "a new thread offers a brief") - sp = system_prompt(g, fresh) - check("Fix the SSE parser" in sp and "Add dark mode" in sp, "a new thread lists both threads") + check("project brief" not in previews(g), "opening a thread writes no brief") - print("-- with a brief") - os.makedirs(os.path.join(DATA, "gritcode", "briefs"), exist_ok=True) - with open(os.path.join(DATA, "gritcode", "briefs", first + ".md"), "w") as f: - f.write("brief-marker: a C++ chat app.") + print("-- a new thread writes it") fresh = g.new_thread(P)["threadId"] - check(not hint_shown(g), "no hint once the folder has a brief") + 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("brief-marker: a C++ chat app." in sp, "the brief is injected") - check("agents-marker" in sp, "together with AGENTS.md") - - print("-- another folder") + 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("brief-marker" not in sp and "Other threads" not in sp, "nothing from P") + check("Fake brief" not in sp and "Other threads" not in sp, "nothing from P") g.new_thread(Q) - check(not hint_shown(g), "no hint without other threads") + 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") finally: os.kill(pid, 15) diff --git a/scripts/smoke/fake_model.py b/scripts/smoke/fake_model.py index c05724c..4694076 100755 --- a/scripts/smoke/fake_model.py +++ b/scripts/smoke/fake_model.py @@ -8,6 +8,8 @@ - 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 @@ -43,6 +45,8 @@ def text_of(message): 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): diff --git a/scripts/smoke/thread_pane.py b/scripts/smoke/thread_pane.py index ba36361..a988b00 100644 --- a/scripts/smoke/thread_pane.py +++ b/scripts/smoke/thread_pane.py @@ -8,7 +8,8 @@ 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 that are cancelled right away. +before launch; the live states come from turns against a local fake model +(fake_model.py) that are cancelled right away. """ import json @@ -24,6 +25,7 @@ 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") @@ -71,7 +73,8 @@ def day(iso): "lastActiveCwd": P}, f) env = dict(os.environ, GRIT_TEST_DATA=DATA) -pid = int(subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}"], +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): diff --git a/scripts/smoke/worktrees.py b/scripts/smoke/worktrees.py deleted file mode 100644 index 53941c8..0000000 --- a/scripts/smoke/worktrees.py +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env python3 -""" -Smoke test for worktree threads, in a temp git repo with the fake model: -a worktree thread writes alongside the folder's main thread, review shows its -diff, merge brings it in, a conflicting merge aborts cleanly, discard asks -before losing work, and a worktree removed by hand is reported. - -Usage: - python3 scripts/smoke/worktrees.py build/gritcode - -Runs the app only through ~/.local/share/grit-dev/run-hidden.sh (invisible -Xvfb, temp HOME and data dir). -""" - -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 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") -MODEL_URL = serve() -DATA = tempfile.mkdtemp(prefix="grit-worktrees-") -PORT = int(os.environ.get("GRIT_MCP_PORT") or random.randint(18600, 18699)) -SESSIONS = os.path.join(DATA, "gritcode", "sessions") -REPO = os.path.join(DATA, "repo") -failures = 0 - - -def launch(): - env = dict(os.environ, GRIT_TEST_DATA=DATA) - out = subprocess.check_output([LAUNCHER, BIN, f"GRIT_MCP_PORT={PORT}", - f"GRIT_FREE_MODEL_URL={MODEL_URL}"], - 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 git(*args): - return subprocess.run(["git", "-C", REPO, *args], capture_output=True, text=True).stdout.strip() - - -def write(path, text): - with open(path, "w") as f: - f.write(text + "\n") - - -def read(path): - try: - with open(path) as f: - return f.read().strip() - except OSError: - return "" - - -def threads(g): - return {t["id"]: t for t in g.list_threads()["threads"]} - - -def wait_idle(g, timeout=120): - end = time.time() + timeout - while time.time() < end: - if not any(t["running"] or t["waiting"] for t in threads(g).values()): - return True - time.sleep(0.5) - return False - - -def bash(cmd): - return f"Call the bash tool now with exactly this command: {cmd}" - - -def previews(g): - return " ".join(b["preview"] for b in g.blocks()) - - -os.makedirs(REPO) -git("init", "-q", "-b", "main") -git("config", "user.name", "Smoke Test") -git("config", "user.email", "smoke@example.invalid") -write(os.path.join(REPO, "README"), "readme") -write(os.path.join(REPO, "shared.txt"), "base") -git("add", "-A") -git("commit", "-q", "-m", "initial") - -pid, g = launch() -try: - g.set_model(0) - main = g.switch_session(REPO)["threadId"] - - print("-- creating a worktree thread") - w = g.new_thread(REPO, worktree=True)["threadId"] - t = threads(g)[w] - wt, branch = t["worktree"], t["branch"] - check(branch.startswith("grit/") and wt.startswith(os.path.join(DATA, "gritcode", "worktrees")), - "it has a branch and a worktree outside the repo") - check(os.path.isdir(wt) and wt in git("worktree", "list"), "git knows the worktree") - check(t["cwd"] == REPO, "it belongs to the project folder") - check(f"isolated git worktree of {REPO}" in g.conversation()[0]["content"] - and branch in g.conversation()[0]["content"], "its system prompt says so") - check("Review changes" in previews(g), "its canvas offers the actions") - with open(os.path.join(SESSIONS, w + ".json")) as f: - saved = json.load(f) - check(saved.get("worktree") == wt and saved.get("branch") == branch, - "the session file records worktree and branch") - g._call("togglePanel", {"panel": "threads"}) - rows = {r["id"]: r for r in g._call("getPanes")["threadRows"]} - check(branch in rows[w]["detail"], "its pane row shows the branch") - - print("-- writing alongside the folder's main thread") - g.select_thread(main) - g.send(bash("sleep 3; echo main > main.txt")) - g.select_thread(w) - g.send(bash("sleep 3; pwd > wt_ran.txt; echo feature > feature.txt")) - now = threads(g) - check(now[main]["running"] and now[w]["running"] and not now[w]["waiting"], - "both run at the same time") - check(wait_idle(g), "both turns finished") - check(read(os.path.join(REPO, "main.txt")) == "main", "the main thread wrote in the repo") - check(read(os.path.join(wt, "wt_ran.txt")) == wt and read(os.path.join(wt, "feature.txt")) == "feature", - "the worktree thread wrote in its worktree") - check(not os.path.exists(os.path.join(REPO, "feature.txt")), "the repo doesn't have its file yet") - - print("-- review") - r = g.worktree_action("review")["notice"] - check("feature.txt" in r and "+feature" in r, "review lists the new file and its diff") - check(any(b["type"] == "code" and "+feature" in b["preview"] for b in g.blocks()), - "the diff shows in the canvas") - - print("-- merge") - head = git("rev-parse", "HEAD") - r = g.worktree_action("merge")["notice"] - check(f"Merged {branch} into main" in r and "committed" in r, "merge reports what it did") - check(read(os.path.join(REPO, "feature.txt")) == "feature", "the change is in the repo") - check(git("rev-parse", "HEAD^1") == head and git("log", "-1", "--format=%s").startswith("Merge"), - "as a merge commit on main") - - print("-- merge refused while the repo has uncommitted changes") - write(os.path.join(REPO, "README"), "edited by the user") - head = git("rev-parse", "HEAD") - r = g.worktree_action("merge")["notice"] - check("uncommitted changes" in r and "README" in r, "it says why") - check(git("rev-parse", "HEAD") == head and read(os.path.join(REPO, "README")) == "edited by the user", - "nothing changed") - git("checkout", "README") - - print("-- a conflicting merge") - write(os.path.join(REPO, "shared.txt"), "project side") - git("commit", "-q", "-am", "project side") - g.send(bash("echo worktree side > shared.txt")) - check(wait_idle(g), "the turn finished") - head = git("rev-parse", "HEAD") - r = g.worktree_action("merge")["notice"] - check("conflicts in shared.txt" in r and "aborted" in r, "it reports the conflict and the abort") - check(any(b["preview"].startswith(r[:100]) for b in g.blocks()), "the notice shows in the canvas") - check(git("rev-parse", "HEAD") == head and git("status", "--porcelain", "--untracked-files=no") == "" - and read(os.path.join(REPO, "shared.txt")) == "project side", - "the repo is as before") - check(subprocess.run(["git", "-C", REPO, "rev-parse", "-q", "--verify", "MERGE_HEAD"], - capture_output=True).returncode != 0, "no merge in progress") - - print("-- discard") - r = g.worktree_action("discard") - check(not r["ok"] and "not merged" in r["asked"], "it asks first (unmerged commit)") - check(os.path.isdir(wt) and git("branch", "--list", branch) != "", "nothing removed without a yes") - r = g.worktree_action("discard", confirm=True) - check(r["ok"] and not os.path.exists(wt) and git("branch", "--list", branch) == "", - "a yes removes worktree and branch") - check(threads(g)[w]["worktree"] == "" and "Review changes" not in previews(g), - "the thread is a plain thread now") - - print("-- a worktree removed by hand") - w2 = g.new_thread(REPO, worktree=True)["threadId"] - wt2, branch2 = threads(g)[w2]["worktree"], threads(g)[w2]["branch"] - stop(pid) - shutil.rmtree(wt2) - pid, g = launch() - check(threads(g)[w2]["worktree"] == wt2, "worktree threads are restored") - g.select_thread(w2) - check("is gone" in previews(g), "its canvas says the worktree is gone") - g.send("hello") - time.sleep(0.5) - check(not threads(g)[w2]["running"] and "can't run" in previews(g), "it doesn't run") - r = g.worktree_action("discard") - check(r["ok"] and r["asked"] == "", "discarding needs no question (no work)") - check(wt2 not in git("worktree", "list") and git("branch", "--list", branch2) == "", - "the stale worktree is pruned and its branch deleted") -finally: - 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 a11fff1..dbf8609 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -9,7 +9,6 @@ #include "run_config_store.h" #include "settings_dialog.h" #include "project_brief.h" -#include "git_worktree.h" #include "image_store.h" #include "debug_window.h" #include "syntax.h" @@ -461,16 +460,6 @@ constexpr const char* kSubagentNote = "inspect (grep, git log, running tests). Nobody can answer questions, so " "state your assumptions in the report."; -// Added to a worktree thread's system prompt. -std::string WorktreeNote(const std::string& project, const std::string& workDir, - const std::string& branch) { - return "You work in an isolated git worktree of " + project + ": " + workDir - + ", on branch " + branch + ". Your tools run there; make every change " - "there, not in " + project + ", where other threads may be working. The " - "project's instructions and brief above apply as they are. When you are " - "done, the user reviews the branch and merges it into the project."; -} - // Model that writes a project brief: DeepSeek Flash when there is a key (it // is cheap and fast), otherwise the free model. Claude runs through its CLI, // which doesn't fit a single request. @@ -553,48 +542,16 @@ Block NoticeBlock(const wxString& msg) { return b; } -// Appends a link to `url` (see OnCanvasLink) to notice `b`. -void AppendLink(Block& b, const wxString& label, const wxString& url) { +// 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 = url; + link.link = wxString::FromUTF8("gritcode://thread/" + id); b.runs.push_back(link); b.rawText += label; b.visibleText += label; -} - -// Appends plain notice text to `b`. -void AppendText(Block& b, const wxString& text) { - InlineRun r; - r.text = text; - r.italic = true; - b.runs.push_back(r); - b.rawText += text; - b.visibleText += text; -} - -// Notice ending in a link that opens thread `id`. -Block ThreadLinkBlock(const wxString& msg, const std::string& id, const wxString& label) { - Block b = NoticeBlock(msg); - AppendLink(b, label, wxString::FromUTF8("gritcode://thread/" + id)); - return b; -} - -// Shown in a new thread of a folder that has other threads but no brief. -Block BriefHintNotice() { - const wxString text = - "This thread starts without the history of the folder's other " - "threads. A project brief tells every thread what the project is " - "about. "; - const wxString linkText = wxString::FromUTF8("Write or generate one\xE2\x80\xA6"); - Block b = NoticeBlock(text); - b.rawText = b.visibleText = text + linkText; - InlineRun link; - link.text = linkText; - link.bold = true; - link.link = "gritcode://brief"; - b.runs.push_back(link); return b; } @@ -1425,17 +1382,6 @@ ChatFrame::ChatFrame() threadsView_ = new wxPanel(importPanel_); { - auto* newBtn = new wxButton(threadsView_, wxID_ANY, "New thread"); - newBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { NewThread(activeCwd_); }); - newWorktreeBtn_ = new wxButton(threadsView_, wxID_ANY, "New thread in worktree"); - newWorktreeBtn_->SetToolTip("A thread that works in its own git worktree, so it can " - "write alongside the folder's other threads"); - newWorktreeBtn_->Bind(wxEVT_BUTTON, - [this](wxCommandEvent&) { NewWorktreeThread(activeCwd_); }); - auto* briefBtn = new wxButton(threadsView_, wxID_ANY, - wxString::FromUTF8("Project brief\xE2\x80\xA6")); - briefBtn->SetToolTip("What every new thread of this folder is told about the project"); - briefBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EditProjectBrief(); }); threadList_ = new ThreadList(threadsView_); // Switch after the click is handled: the switch replaces the rows. threadList_->Bind(wxEVT_LISTBOX, [this](wxCommandEvent& e) { @@ -1446,27 +1392,37 @@ ChatFrame::ChatFrame() }); threadList_->Bind(wxEVT_CONTEXT_MENU, &ChatFrame::OnThreadListContextMenu, this); auto* sizer = new wxBoxSizer(wxVERTICAL); - sizer->Add(newBtn, 0, wxEXPAND | wxBOTTOM, 4); - sizer->Add(newWorktreeBtn_, 0, wxEXPAND | wxBOTTOM, 4); - sizer->Add(briefBtn, 0, wxEXPAND | wxBOTTOM, 4); sizer->Add(threadList_, 1, wxEXPAND); threadsView_->SetSizer(sizer); } - sessionTab_ = new wxToggleButton(importPanel_, wxID_ANY, "Session"); - sessionTab_->SetToolTip("A saved session to copy prompts from"); - threadsTab_ = new wxToggleButton(importPanel_, wxID_ANY, "Threads"); + // 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"); - sessionTab_->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { ShowLeftView(false); }); - threadsTab_->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent&) { ShowLeftView(true); }); - auto* tabs = new wxBoxSizer(wxHORIZONTAL); - tabs->Add(sessionTab_, 1); - tabs->Add(threadsTab_, 1); + 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(tabs, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 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 @@ -1703,45 +1659,20 @@ ChatFrame::ChatFrame() {"failed", t && t->failureNotice.has_value()}, }); }; - for (const Thread& t : threads_) { + for (const Thread& t : threads_) add(t.id, t.cwd, t.title, t.parent, t.lastUsed, &t); - arr.back()["worktree"] = t.worktree; - arr.back()["branch"] = t.branch; - } - for (const auto& e : store_.List()) { - if (Find(e.id)) continue; - add(e.id, e.cwd, e.title, e.parent, e.lastUsed, nullptr); - arr.back()["worktree"] = e.worktree; - arr.back()["branch"] = e.branch; - } + 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, bool worktree) -> nlohmann::json { - return guiSync([this, cwd, worktree]() -> nlohmann::json { - const std::string folder = cwd.empty() ? activeCwd_ : cwd; - if (!(worktree ? NewWorktreeThread(folder) : NewThread(folder))) - return {{"ok", false}, {"reason", worktree ? "worktree failed" : "cancelled"}}; + 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.worktreeAction = [this, guiSync](const std::string& action, - bool confirm) -> nlohmann::json { - return guiSync([this, action, confirm]() -> nlohmann::json { - Thread& t = Active(); - if (t.worktree.empty()) return {{"ok", false}, {"notice", "not a worktree thread"}}; - if (action == "review") return {{"ok", true}, {"notice", ReviewChanges(t)}}; - if (action == "merge") return {{"ok", true}, {"notice", MergeWorktreeThread(t)}}; - if (action != "discard") return {{"ok", false}, {"notice", "unknown action"}}; - std::string asked; - const auto notice = DiscardWorktree(t, [&](const wxString& question) { - asked = question.utf8_string(); - return confirm; - }); - return {{"ok", notice.has_value()}, {"notice", notice.value_or("")}, - {"asked", asked}}; - }); - }; 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"}}; @@ -2396,6 +2327,7 @@ 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)); } @@ -2437,7 +2369,7 @@ std::vector ChatFrame::FolderRows() { 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), .branch = t.branch}; + .detail = LastUsedLabel(t.lastUsed)}; if (t.streaming) { r.state = t.waiting ? ThreadRow::State::Waiting : ThreadRow::State::Running; r.detail = CurrentStep(t); @@ -2450,15 +2382,9 @@ std::vector ChatFrame::FolderRows() { 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), .branch = e.branch}, + .detail = LastUsedLabel(e.lastUsed)}, e.parent, e.lastUsed}); } - // A worktree thread shows its branch while it isn't busy. - for (Row& r : rows) { - if (!r.row.branch.empty() && r.row.state == ThreadRow::State::Done) - r.row.detail = wxString::FromUTF8(r.row.branch) + wxString::FromUTF8(" \xC2\xB7 ") - + r.row.detail; // "·" - } std::ranges::stable_sort(rows, std::greater{}, &Row::lastUsed); // Subagent threads go right under their parent. @@ -2480,7 +2406,7 @@ std::vector ChatFrame::FolderRows() { wxString ChatFrame::CurrentStep(Thread& t) { if (t.waiting) { - const Thread* r = RunningIn(t.workDir, t); + const Thread* r = RunningIn(t.cwd, t); return "waiting for " + (r ? ThreadLabel(r->id, r->cwd, r->title) : wxString("the folder")); } @@ -2538,11 +2464,6 @@ void ChatFrame::RefreshSessionChoice() { if (active != sessionCwds_.end()) sessionChoice_->SetSelection(1 + (int)(active - sessionCwds_.begin())); sessionChoice_->SetToolTip(wxString::FromUTF8(activeCwd_)); - const bool git = !FindGitRoot(activeCwd_).empty(); - if (newWorktreeBtn_->IsShown() != git) { - newWorktreeBtn_->Show(git); - threadsView_->Layout(); - } if (ThreadsShown()) UpdateThreadPane(FolderRows()); } @@ -2606,43 +2527,13 @@ bool ChatFrame::NewThread(const std::string& cwd) { // behind. Thread& t = OpenThread(SessionStore::NewThreadId(), cwd); if (ShowThread(t.id)) { - if (store_.LoadBrief(cwd).empty() && !FolderThreads(cwd).empty()) - canvas_->AddBlock(BriefHintNotice()); + if (store_.LoadBrief(cwd).empty() && WriteProjectBrief(cwd)) AddBriefNotice(t); return true; } threads_.pop_back(); // cancelled; nothing refers to it yet return false; } -bool ChatFrame::NewWorktreeThread(const std::string& cwd) { - const std::string id = SessionStore::NewThreadId(); - const std::string branch = "grit/" + id.substr(0, 8); - const std::string path = store_.WorktreePath(id); - if (std::string error = AddWorktree(cwd, path, branch); !error.empty()) { - RenderErrorBlock(Active(), "Couldn't create a worktree: " + wxString::FromUTF8(error)); - return false; - } - Thread& t = OpenThread(id, cwd); - t.worktree = path; - t.branch = branch; - t.workDir = WorktreeDir(cwd, path); - RefreshSystemPrompt(t); - if (!ShowThread(id)) { - threads_.pop_back(); // cancelled; nothing refers to it yet - RemoveWorktree(cwd, path, branch); - return false; - } - // Saved right away, unlike a plain new thread: it owns a worktree, which - // must stay listed (and discardable) even if the thread is never used. - Persist(t); - const std::string from = GitBranch(cwd); - RenderErrorBlock(t, wxString::FromUTF8( - "Created the worktree " + path + " on branch " + branch + " from " - + (from.empty() ? std::string("HEAD") : from) + ". It holds the committed " - "state only: uncommitted changes in " + cwd + " aren't in it.")); - return true; -} - bool ChatFrame::SelectThread(const std::string& id) { if (!Find(id)) { const auto& entries = store_.List(); @@ -2665,10 +2556,7 @@ ChatFrame::Thread& ChatFrame::OpenThread(const std::string& id, t.title = e->title; t.parent = e->parent; t.lastUsed = e->lastUsed; - t.worktree = e->worktree; - t.branch = e->branch; } - t.workDir = t.worktree.empty() ? cwd : WorktreeDir(cwd, t.worktree); if (store_.Load(id, t.history)) { RefreshSystemPrompt(t); // Sessions from before threads have no title yet. @@ -2725,7 +2613,6 @@ void ChatFrame::Persist(Thread& t) { const SessionStore::Entry entry{ .id = t.id, .cwd = t.cwd, .lastUsed = t.lastUsed, .title = t.title, .parent = t.parent, - .worktree = t.worktree, .branch = t.branch, }; std::vector msgs = t.history; @@ -2746,139 +2633,12 @@ void ChatFrame::Persist(Thread& t) { }); } -std::string ChatFrame::ReviewChanges(Thread& t) { - const WorktreeDiff d = ReviewWorktree(t.cwd, t.worktree); - std::string notice; - if (!d.error.empty()) notice = "Can't show the changes: " + d.error; - else if (d.diff.empty()) notice = "No changes on " + t.branch + " yet."; - else notice = "Changes on " + t.branch + " since it branched off (committed and " - "uncommitted):\n" + d.stat; - RenderErrorBlock(t, wxString::FromUTF8(notice)); - if (!d.diff.empty() && Shown(t)) { - // A huge diff is cut at a line end, so the text stays valid UTF-8. - constexpr size_t kMaxShown = 200'000; - std::string text = d.diff; - if (text.size() > kMaxShown) { - text.resize(text.rfind('\n', kMaxShown) + 1); - text += "... [cut; git diff in " + t.worktree + " shows the rest]\n"; - } - Block b; - b.type = BlockType::CodeBlock; - b.lang = "diff"; - b.rawText = b.visibleText = wxString::FromUTF8(text); - InlineRun r; - r.text = b.rawText; - r.code = true; - b.runs.push_back(std::move(r)); - canvas_->AddBlock(std::move(b)); - } - return notice + "\n" + d.diff; -} - -std::string ChatFrame::MergeWorktreeThread(Thread& t) { - std::string notice; - if (t.streaming || HasRunningChild(t)) { - notice = "This thread is still running. Merge when its turn has ended (or stop it)."; - } else if (const Thread* other = RunningIn(t.cwd, t)) { - notice = ThreadLabel(other->id, other->cwd, other->title).utf8_string() - + " is running in the project folder. Merge when it's done, so the " - "merge doesn't change files under it."; - } else { - const std::string message = - t.title.empty() ? "Work from Gritcode thread " + t.id : t.title; - notice = MergeWorktree(t.cwd, t.worktree, t.branch, message).message; - } - RenderErrorBlock(t, wxString::FromUTF8(notice)); - return notice; -} - -std::optional ChatFrame::DiscardWorktree( - Thread& t, const std::function& confirm) { - std::string notice; - if (t.streaming || HasRunningChild(t)) { - notice = "This thread is still running. Discard its worktree when its turn has " - "ended (or stop it)."; - RenderErrorBlock(t, wxString::FromUTF8(notice)); - return notice; - } - // Never lose work silently: ask when the worktree holds anything that - // isn't in the project yet, or when git can't tell. - const WorktreeWork w = UnmergedWork(t.cwd, t.worktree, t.branch); - std::string loses; - if (!w.error.empty()) { - loses = "Gritcode couldn't check what it holds (" + w.error + ")."; - } else if (w.unmergedCommits > 0 || w.changedFiles > 0) { - std::string target = GitBranch(t.cwd); - if (target.empty()) target = "the project"; - if (w.unmergedCommits > 0) - loses = t.branch + " has " + std::to_string(w.unmergedCommits) + " commit(s) not " - "merged into " + target + "."; - if (w.changedFiles > 0) - loses += (loses.empty() ? "" : " ") + std::string("The worktree has uncommitted " - "changes in ") + std::to_string(w.changedFiles) + " file(s)."; - loses += " Discarding deletes them."; - } - if (!loses.empty() - && !confirm(wxString::FromUTF8("Discard the worktree of this thread? " + loses))) - return std::nullopt; - - const std::string path = t.worktree; - const std::string branch = t.branch; - if (std::string error = RemoveWorktree(t.cwd, path, branch); !error.empty()) { - notice = "Couldn't discard the worktree: " + error; - } else { - t.worktree.clear(); - t.branch.clear(); - t.workDir = t.cwd; - RefreshSystemPrompt(t); - Persist(t); - if (Shown(t)) RestoreCanvasFromHistory(); - RefreshSessionChoice(); - notice = "Discarded the worktree " + path + " and branch " + branch - + ". This thread now works in " + t.cwd + "."; - } - RenderErrorBlock(t, wxString::FromUTF8(notice)); - return notice; -} - -void ChatFrame::DiscardWorktreeAsking(Thread& t) { - DiscardWorktree(t, [this](const wxString& question) { - return wxMessageBox(question, "Discard worktree", - wxYES_NO | wxNO_DEFAULT | wxICON_WARNING, this) == wxYES; - }); -} - -Block ChatFrame::WorktreeHeader(const Thread& t) { - if (!wxDirExists(wxString::FromUTF8(t.worktree))) { - Block b = NoticeBlock(wxString::FromUTF8( - "This thread's worktree " + t.worktree + " is gone (removed outside Gritcode), " - "so the thread can't run. Its branch " + t.branch + " may still hold committed " - "work: git merge " + t.branch + " in the project brings that in. ")); - AppendLink(b, "Discard worktree", "gritcode://worktree/discard"); - AppendText(b, " deletes the branch, and the thread works in the project folder."); - return b; - } - const std::string target = GitBranch(t.cwd); - Block b = NoticeBlock(wxString::FromUTF8( - "Worktree thread: works in " + t.workDir + " on branch " + t.branch - + ", apart from the folder's other threads. ")); - AppendLink(b, "Review changes", "gritcode://worktree/review"); - AppendText(b, wxString::FromUTF8(" \xC2\xB7 ")); // "·" - AppendLink(b, "Merge into " + wxString::FromUTF8( - target.empty() ? std::string("the project") : target), - "gritcode://worktree/merge"); - AppendText(b, wxString::FromUTF8(" \xC2\xB7 ")); - AppendLink(b, "Discard worktree", "gritcode://worktree/discard"); - return b; -} - std::string ChatFrame::SystemPrompt(Thread& t) { t.lastAgentsContent = LoadAgentsInstructions(t.cwd); std::string content = BaseSystemPrompt(); if (!t.lastAgentsContent.empty()) content += "\n\n" + t.lastAgentsContent; if (std::string folder = FolderContext(t); !folder.empty()) content += "\n\n" + folder; - if (!t.worktree.empty()) content += "\n\n" + WorktreeNote(t.cwd, t.workDir, t.branch); if (!t.parent.empty()) content += kSubagentNote; return content; } @@ -2922,8 +2682,8 @@ std::string ChatFrame::FolderContext(const Thread& t) const { } std::string out; if (std::string brief = store_.LoadBrief(t.cwd); !brief.empty()) - out = "Project brief for " + t.cwd + " (kept by the user, may be " - "out of date):\n" + brief; + 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 " @@ -2933,8 +2693,17 @@ std::string ChatFrame::FolderContext(const Thread& t) const { return out; } -void ChatFrame::EditProjectBrief() { - const std::string cwd = activeCwd_; +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 = BriefRoute(remoteModels_); @@ -2947,16 +2716,54 @@ void ChatFrame::EditProjectBrief() { .deepseek = route.needsApiKey, .label = ModelLabelForId(route.model), }; - const auto brief = ShowBriefDialog(this, DisplayPath(cwd), store_.LoadBrief(cwd), model, - BriefMaterial(cwd, LoadAgentsInstructions(cwd), titles)); - if (!brief) return; - if (!store_.SaveBrief(cwd, *brief)) { - wxMessageBox("The project brief could not be saved.", "Project brief", - wxOK | wxICON_ERROR, this); + job->writer = model.label; + job->running = true; + job->request = WriteBrief(this, model, 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; } - for (Thread& t : threads_) - if (t.cwd == cwd) RefreshSystemPrompt(t); + 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() { @@ -2967,7 +2774,6 @@ void ChatFrame::RestoreCanvasFromHistory() { if (!t.parent.empty()) canvas_->AddBlock(ThreadLinkBlock("Subagent thread (view only), started by ", t.parent, ThreadLabelOf(t.parent))); - if (!t.worktree.empty()) canvas_->AddBlock(WorktreeHeader(t)); ModelSwitchTracker modelTracker; // Walk the message list and re-emit blocks. Tool call/result pairs are // stitched back together: an assistant message's tool_calls are matched @@ -3061,6 +2867,7 @@ void ChatFrame::RestoreCanvasFromHistory() { // 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; + 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)); @@ -3302,13 +3109,6 @@ void ChatFrame::OnCanvasLink(wxCommandEvent& e) { OnSettings(ev); } else if (e.GetString().StartsWith("gritcode://thread/", &threadId)) { SelectThread(threadId.utf8_string()); - } else if (e.GetString() == "gritcode://brief") { - EditProjectBrief(); - } else if (wxString action; e.GetString().StartsWith("gritcode://worktree/", &action) - && !Active().worktree.empty()) { - if (action == "review") ReviewChanges(Active()); - else if (action == "merge") MergeWorktreeThread(Active()); - else if (action == "discard") DiscardWorktreeAsking(Active()); } } @@ -3330,16 +3130,26 @@ void ChatFrame::OnHamburger(wxCommandEvent&) { } void ChatFrame::ShowLeftView(bool threads) { - sessionTab_->SetValue(!threads); - threadsTab_->SetValue(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(); } @@ -3349,30 +3159,13 @@ void ChatFrame::OnThreadListContextMenu(wxContextMenuEvent& e) { ? threadList_->GetSelection() : threadList_->HitTest(threadList_->ScreenToClient(e.GetPosition())); if (n == wxNOT_FOUND) return; - const ThreadRow row = threadList_->Rows()[n]; - const Thread* t = Find(row.id); - enum { ID_REVIEW = wxID_HIGHEST + 1, ID_MERGE, ID_DISCARD }; + const std::string id = threadList_->Rows()[n].id; + const Thread* t = Find(id); + if (!t || !t->streaming) return; wxMenu menu; - if (t && t->streaming) menu.Append(wxID_STOP, "Stop"); - if (!row.branch.empty()) { - const std::string target = GitBranch(activeCwd_); - menu.Append(ID_REVIEW, "Review changes"); - menu.Append(ID_MERGE, "Merge into " + wxString::FromUTF8( - target.empty() ? std::string("the project") : target)); - menu.Append(ID_DISCARD, "Discard worktree"); - } - if (menu.GetMenuItemCount() == 0) return; - const int choice = threadList_->GetPopupMenuSelectionFromUser(menu); - if (choice == wxID_STOP) { - if (Thread* still = Find(row.id); still && still->streaming) RequestCancel(*still); - return; - } - if (choice != ID_REVIEW && choice != ID_MERGE && choice != ID_DISCARD) return; - // The outcome shows in the thread's canvas. - if (!SelectThread(row.id) || Active().worktree.empty()) return; - if (choice == ID_REVIEW) ReviewChanges(Active()); - else if (choice == ID_MERGE) MergeWorktreeThread(Active()); - else DiscardWorktreeAsking(Active()); + 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&) { @@ -4544,10 +4337,6 @@ void ChatFrame::OnSend(wxCommandEvent&) { input_->Clear(); return; } - if (!t.worktree.empty() && !wxDirExists(wxString::FromUTF8(t.worktree))) { - RenderErrorBlock(t, "This thread can't run: its worktree is gone (see above)."); - return; - } input_->Clear(); std::vector images = std::move(pendingImages_); @@ -4607,10 +4396,9 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, UpdateQueueUI(); // Send becomes "Add", chip row stays visible if non-empty. } - // One running turn per folder (a worktree thread's checkout is a folder - // of its own): until the other thread there finishes, this one waits - // (StartNextWaiting starts it). - if (RunningIn(t.workDir, t)) { + // One running turn per folder: until the other thread there finishes, + // this one waits (StartNextWaiting starts it). + if (RunningIn(t.cwd, t)) { t.waiting = true; RenderWaitingNotice(t); } else { @@ -4619,14 +4407,14 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, RefreshSessionChoice(); // marks the thread as running } -ChatFrame::Thread* ChatFrame::RunningIn(const std::string& dir, const Thread& self) { +ChatFrame::Thread* ChatFrame::RunningIn(const std::string& cwd, const Thread& self) { // A thread and its subagents count as one owner of the folder (the // subagents are read-only, see IsWriteTool). auto family = [](const Thread& t) -> const std::string& { return t.parent.empty() ? t.id : t.parent; }; for (Thread& t : threads_) { - if (&t != &self && t.workDir == dir && t.streaming && !t.waiting + if (&t != &self && t.cwd == cwd && t.streaming && !t.waiting && family(t) != family(self)) return &t; } @@ -4637,10 +4425,10 @@ int ChatFrame::ModelIndexFor(const Thread& t) const { return t.parent.empty() ? currentModelIndex_ : t.turnModelIndex; } -void ChatFrame::StartNextWaiting(const std::string& dir) { +void ChatFrame::StartNextWaiting(const std::string& cwd) { for (Thread& t : threads_) { - if (!t.waiting || t.workDir != dir) continue; - if (RunningIn(dir, t)) return; + if (!t.waiting || t.cwd != cwd) continue; + if (RunningIn(cwd, t)) return; t.waiting = false; StartCompletion(t); return; @@ -4648,7 +4436,7 @@ void ChatFrame::StartNextWaiting(const std::string& dir) { } void ChatFrame::RenderWaitingNotice(Thread& t) { - const Thread* other = RunningIn(t.workDir, t); + const Thread* other = RunningIn(t.cwd, t); if (!other) return; RenderErrorBlock(t, "Waiting for " + ThreadLabel(other->id, other->cwd, other->title) + wxString::FromUTF8(" to finish\xE2\x80\xA6")); @@ -4837,8 +4625,8 @@ void ChatFrame::DoSendActualRequest(Thread& t) { && messages[0].value("role", std::string{}) == "system") { std::string base = messages[0].value("content", std::string{}); std::string extra; - if (!t.workDir.empty()) - extra += "\n\nWorking directory: " + t.workDir; + 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 @@ -5386,7 +5174,7 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, return m.is_object() && m.value("isSummary", false); }); ++t.gen; - t.toolWorker = std::thread([this, jobs, token, dir = t.workDir, folder = t.cwd, enableGrit, + 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; @@ -5398,11 +5186,8 @@ void ChatFrame::HandleCompletion(Thread& t, const wxString& errorIfFailed, } else if (!job.parseError.empty()) { r = std::move(job.parseError); } else { - // History search's scope "folder" is the project folder, - // where a worktree thread's history is indexed too. - r = DispatchTool(job.name, job.argsParsed, token.get(), &memory_, - job.name == "grit_history_search" ? folder : dir, - enableGrit, searchExclude); + r = DispatchTool(job.name, job.argsParsed, token.get(), + &memory_, cwd, enableGrit, searchExclude); } results.push_back({std::move(job.id), std::move(job.name), std::move(job.argsJson), std::move(r)}); @@ -5499,7 +5284,7 @@ void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { // The folder is free: a thread waiting for it goes before this thread's // own queue, which would otherwise keep the folder to itself. - StartNextWaiting(t.workDir); + StartNextWaiting(t.cwd); if (!t.parentToolId.empty()) ReportToParent(t, wasCancelledOrError); // Natural completion: auto-dispatch the next queued message so back-to- @@ -5538,7 +5323,6 @@ bool ChatFrame::HasRunningChild(const Thread& t) const { ChatFrame::Thread& ChatFrame::OpenChild(Thread& parent, const std::string& toolId, const std::string& description) { Thread& c = OpenThread(SessionStore::NewThreadId(), parent.cwd); - c.workDir = parent.workDir; c.parent = parent.id; c.parentToolId = toolId; c.title = DefaultTitle(description.empty() ? std::string("Subagent") : description); @@ -6454,7 +6238,6 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { if (!agents.empty()) append += "\n\n" + agents; if (std::string folder = FolderContext(t); !folder.empty()) append += "\n\n" + folder; - if (!t.worktree.empty()) append += "\n\n" + WorktreeNote(t.cwd, t.workDir, t.branch); const std::string promptFile = writeTemp(append); // gritcode's own stdio MCP server, in its run_project-only mode, so the @@ -6465,7 +6248,7 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { {"gritcode", { {"type", "stdio"}, {"command", wxStandardPaths::Get().GetExecutablePath().utf8_string()}, - {"args", {"--mcp-stdio", "--run-project", t.workDir}}, + {"args", {"--mcp-stdio", "--run-project", t.cwd}}, }}, }}, }; @@ -6474,7 +6257,7 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { ClaudeRunSpec spec; spec.executable = exe; - spec.cwd = t.workDir; + spec.cwd = t.cwd; spec.args = { "-p", "--input-format", "stream-json", diff --git a/src/chat_frame.h b/src/chat_frame.h index e163b71..f5c9866 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -26,7 +25,6 @@ #include #include #include -#include #include #include #include @@ -122,19 +120,10 @@ class ChatFrame : public wxFrame { // their data only. struct Thread { std::string id; // session file id (SessionStore::Entry::id) - std::string cwd; // the folder (project) it belongs to + 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 worktree thread writes in its own git checkout (worktree root, on - // `branch`), so it runs alongside the folder's other threads; both - // are empty for a thread working in the folder itself. - std::string worktree; - std::string branch; - // Where its tools and Claude Code run: `cwd`, or the same folder in - // its worktree (a subagent: its parent's). One running turn per - // workDir (see RunningIn). - std::string workDir; // 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 @@ -475,15 +464,15 @@ class ChatFrame : public wxFrame { void RefreshSessionChoice(); // ---- Left pane threads view ---- - // The left pane (hamburger) switches between the session reference - // ("Session") and the active folder's threads ("Threads"); the mode is + // 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. - wxToggleButton* sessionTab_ = nullptr; - wxToggleButton* threadsTab_ = nullptr; + 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; - wxButton* newWorktreeBtn_ = nullptr; // shown when the folder is a git repo // 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 @@ -495,6 +484,8 @@ class ChatFrame : public wxFrame { 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); @@ -507,9 +498,6 @@ class ChatFrame : public wxFrame { void CreateNewSession(); bool SwitchToCwd(const std::string& cwd); bool NewThread(const std::string& cwd); - // NewWorktreeThread adds an empty thread of `cwd` that works in a new git - // worktree; failures show as a notice. Only offered in git repos. - bool NewWorktreeThread(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. @@ -521,24 +509,6 @@ class ChatFrame : public wxFrame { // Persist the thread's history to its session file. void Persist(Thread& t); - // ---- Worktree threads ---- - // Review shows the worktree's diff against where it branched off; Merge - // commits its uncommitted changes on its branch and merges that into the - // project's branch; Discard removes the worktree and branch after - // `confirm` agreed to what would be lost (unmerged commits, uncommitted - // changes), and the thread works in the folder from then on. Each shows - // its outcome in t's canvas and returns it; Discard returns nothing when - // not confirmed. - std::string ReviewChanges(Thread& t); - std::string MergeWorktreeThread(Thread& t); - std::optional DiscardWorktree( - Thread& t, const std::function& confirm); - // Discard, asking the user with a dialog. - void DiscardWorktreeAsking(Thread& t); - // The actions' header at the top of a worktree thread's canvas, or the - // notice that its worktree is gone. - Block WorktreeHeader(const 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 — @@ -566,9 +536,24 @@ class ChatFrame : public wxFrame { // new thread knows what the project is and that parallel work exists. // Empty when the folder has neither. std::string FolderContext(const Thread& t) const; - // "Project brief…": edit (or generate) the active folder's brief, then - // refresh the system prompts of its open threads. - void EditProjectBrief(); + + // A folder's project brief is written once, when the user starts a new + // thread in a folder without one, 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; + StreamingWebRequest request; + }; + 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 @@ -665,11 +650,10 @@ class ChatFrame : public wxFrame { void StartTurn(Thread& t, const wxString& userText, std::vector images = {}, const std::string& hiddenInstructions = std::string()); - // The thread (other than `self` and its family) running a turn in work - // dir `dir`, if any. - Thread* RunningIn(const std::string& dir, const Thread& self); - // Start the first turn waiting for work dir `dir`, unless one runs there. - void StartNextWaiting(const std::string& dir); + // The thread (other than `self`) running a turn in `cwd`, if any. + Thread* RunningIn(const std::string& cwd, const Thread& self); + // Start the first turn waiting for `cwd`, unless one is running there. + void StartNextWaiting(const std::string& cwd); void EnqueueMessage(Thread& t, const wxString& text); void LogDebug(const std::string& line); void OpenDebugWindow(); diff --git a/src/git_worktree.cpp b/src/git_worktree.cpp deleted file mode 100644 index d5abe52..0000000 --- a/src/git_worktree.cpp +++ /dev/null @@ -1,272 +0,0 @@ -#include "git_worktree.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; - -namespace { - -struct GitRun { - int code = -1; // exit status; -1 when git didn't exit normally - std::string out; - std::string err; -}; - -// Runs `git -C dir args...` (fork/exec, no shell) and collects its output. -GitRun Git(const std::string& dir, std::vector args) { - args.insert(args.begin(), {"git", "-C", dir}); - std::vector argv; - for (std::string& a : args) argv.push_back(a.data()); - argv.push_back(nullptr); - - GitRun r; - int out[2], err[2]; - if (pipe(out) < 0) return r; - if (pipe(err) < 0) { - close(out[0]); close(out[1]); - return r; - } - for (int fd : {out[0], out[1], err[0], err[1]}) fcntl(fd, F_SETFD, FD_CLOEXEC); - const int devNull = open("/dev/null", O_RDONLY | O_CLOEXEC); - - const pid_t pid = fork(); - if (pid == 0) { - if (devNull >= 0) dup2(devNull, STDIN_FILENO); - dup2(out[1], STDOUT_FILENO); - dup2(err[1], STDERR_FILENO); - execvp("git", argv.data()); - const char msg[] = "could not run git\n"; - (void)!write(STDERR_FILENO, msg, sizeof msg - 1); - _exit(127); - } - close(out[1]); - close(err[1]); - if (devNull >= 0) close(devNull); - if (pid < 0) { - close(out[0]); close(err[0]); - r.err = "could not start git"; - return r; - } - - // Drain both pipes together so neither fills up and blocks git. - pollfd fds[2] = {{out[0], POLLIN, 0}, {err[0], POLLIN, 0}}; - std::string* sinks[2] = {&r.out, &r.err}; - int openFds = 2; - char buf[65536]; - while (openFds > 0) { - if (poll(fds, 2, -1) < 0) { - if (errno == EINTR) continue; - break; - } - for (int i = 0; i < 2; ++i) { - if (fds[i].fd < 0 || !fds[i].revents) continue; - const ssize_t n = read(fds[i].fd, buf, sizeof buf); - if (n > 0) { - sinks[i]->append(buf, (size_t)n); - } else if (n == 0 || errno != EINTR) { - close(fds[i].fd); - fds[i].fd = -1; - --openFds; - } - } - } - for (const pollfd& p : fds) - if (p.fd >= 0) close(p.fd); - int status = 0; - while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {} - if (WIFEXITED(status)) r.code = WEXITSTATUS(status); - return r; -} - -std::string Trim(std::string s) { - while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back(); - return s; -} - -// What went wrong, in git's words. -std::string Failure(const GitRun& r) { - std::string msg = Trim(r.err.empty() ? r.out : r.err); - return msg.empty() ? "git failed" : msg; -} - -std::vector Lines(const std::string& s) { - std::vector lines; - std::istringstream in(s); - for (std::string line; std::getline(in, line);) - if (!line.empty()) lines.push_back(line); - return lines; -} - -// "a, b, c and 4 more" -std::string FileList(const std::vector& files) { - constexpr size_t kShown = 5; - std::string out; - for (size_t i = 0; i < files.size() && i < kShown; ++i) - out += (i ? ", " : "") + files[i]; - if (files.size() > kShown) out += " and " + std::to_string(files.size() - kShown) + " more"; - return out; -} - -} // namespace - -std::string GitTopLevel(const std::string& dir) { - GitRun r = Git(dir, {"rev-parse", "--show-toplevel"}); - return r.code == 0 ? Trim(r.out) : std::string(); -} - -std::string GitBranch(const std::string& dir) { - GitRun r = Git(dir, {"symbolic-ref", "--quiet", "--short", "HEAD"}); - return r.code == 0 ? Trim(r.out) : std::string(); -} - -std::string AddWorktree(const std::string& project, const std::string& worktree, - const std::string& branch) { - std::error_code ec; - fs::create_directories(fs::path(worktree).parent_path(), ec); - GitRun r = Git(project, {"worktree", "add", "-b", branch, worktree, "HEAD"}); - return r.code == 0 ? std::string() : Failure(r); -} - -std::string WorktreeDir(const std::string& project, const std::string& worktree) { - const std::string top = GitTopLevel(project); - std::error_code ec; - const fs::path rel = top.empty() ? fs::path() : fs::relative(project, top, ec); - if (ec || rel.empty() || rel == "." || *rel.begin() == "..") return worktree; - return (fs::path(worktree) / rel).string(); -} - -WorktreeWork UnmergedWork(const std::string& project, const std::string& worktree, - const std::string& branch) { - WorktreeWork w; - if (fs::exists(worktree)) { - GitRun s = Git(worktree, {"status", "--porcelain"}); - if (s.code != 0) { - w.error = Failure(s); - return w; - } - w.changedFiles = (int)Lines(s.out).size(); - } - GitRun c = Git(project, {"rev-list", "--count", "HEAD.." + branch}); - if (c.code != 0) w.error = Failure(c); - else w.unmergedCommits = std::atoi(c.out.c_str()); - return w; -} - -WorktreeDiff ReviewWorktree(const std::string& project, const std::string& worktree) { - WorktreeDiff d; - if (!fs::exists(worktree)) { - d.error = "The worktree " + worktree + " no longer exists."; - return d; - } - GitRun head = Git(project, {"rev-parse", "HEAD"}); - if (head.code != 0) { - d.error = Failure(head); - return d; - } - // Where the worktree branched off the project's branch, even when that - // has moved on since (e.g. another worktree was merged). - GitRun base = Git(worktree, {"merge-base", "HEAD", Trim(head.out)}); - if (base.code != 0) { - d.error = Failure(base); - return d; - } - if (GitRun add = Git(worktree, {"add", "-A"}); add.code != 0) { - d.error = Failure(add); - return d; - } - GitRun stat = Git(worktree, {"diff", "--cached", "--no-color", "--stat", Trim(base.out)}); - GitRun diff = Git(worktree, {"diff", "--cached", "--no-color", "--no-ext-diff", - Trim(base.out)}); - if (diff.code != 0) { - d.error = Failure(diff); - return d; - } - d.stat = Trim(stat.out); - d.diff = std::move(diff.out); - return d; -} - -MergeOutcome MergeWorktree(const std::string& project, const std::string& worktree, - const std::string& branch, const std::string& commitMessage) { - const std::string target = GitBranch(project); - if (target.empty()) - return {false, "The project checkout isn't on a branch (detached HEAD). Check out " - "the branch to merge into, then merge again. Nothing was changed."}; - GitRun dirty = Git(project, {"status", "--porcelain", "--untracked-files=no"}); - if (dirty.code != 0) return {false, "Can't merge: " + Failure(dirty)}; - if (std::vector files = Lines(dirty.out); !files.empty()) { - for (std::string& f : files) f = f.substr(3); // drop the "XY " status - return {false, "The project checkout has uncommitted changes (" + FileList(files) - + "). Commit or stash them, then merge again. Nothing was changed."}; - } - if (!fs::exists(worktree)) - return {false, "The worktree " + worktree + " no longer exists. Nothing was changed."}; - - // The worktree's uncommitted work becomes a commit on its branch. - bool committed = false; - if (GitRun add = Git(worktree, {"add", "-A"}); add.code != 0) - return {false, "Can't commit the worktree's changes: " + Failure(add)}; - if (Git(worktree, {"diff", "--cached", "--quiet"}).code == 1) { - GitRun commit = Git(worktree, {"commit", "-q", "-m", commitMessage}); - if (commit.code != 0) - return {false, "Committing the worktree's changes on " + branch + " failed: " - + Failure(commit) + "\nNothing was merged."}; - committed = true; - } - const std::string committedNote = - committed ? "The worktree's uncommitted changes were committed on " + branch - + " as \"" + commitMessage + "\". " - : std::string(); - - GitRun ahead = Git(project, {"rev-list", "--count", "HEAD.." + branch}); - if (ahead.code != 0) return {false, committedNote + "Can't merge: " + Failure(ahead)}; - if (std::atoi(ahead.out.c_str()) == 0) - return {false, committedNote + "Nothing to merge: " + target + " already has " - "everything on " + branch + "."}; - - GitRun merge = Git(project, {"merge", "--no-ff", "--no-edit", "-m", - "Merge " + branch + " (Gritcode worktree thread)", branch}); - if (merge.code == 0) - return {true, committedNote + "Merged " + branch + " into " + target + "."}; - - // Refused before it started (e.g. it would overwrite untracked files): - // nothing changed. - if (Git(project, {"rev-parse", "-q", "--verify", "MERGE_HEAD"}).code != 0) - return {false, committedNote + "Merging " + branch + " into " + target + " failed: " - + Failure(merge) + "\n" + target + " is unchanged."}; - - // Stopped on conflicts: put the user's checkout back as it was. - const std::string conflicts = - FileList(Lines(Git(project, {"diff", "--name-only", "--diff-filter=U"}).out)); - const std::string what = "Merging " + branch + " into " + target + " hit conflicts" - + (conflicts.empty() ? std::string() : " in " + conflicts); - if (GitRun abort = Git(project, {"merge", "--abort"}); abort.code != 0) - return {false, committedNote + what + ", and git merge --abort failed: " - + Failure(abort) + "\nThe project checkout " + project - + " is still in the middle of the merge: resolve the conflicts " - "and commit, or run git merge --abort there."}; - return {false, committedNote + what + ". The merge was aborted; " + target - + " and the project checkout are as before. To bring the work in, " - "ask this thread to merge " + target + " into its branch and " - "resolve the conflicts in its worktree, then merge again."}; -} - -std::string RemoveWorktree(const std::string& project, const std::string& worktree, - const std::string& branch) { - GitRun r = fs::exists(worktree) - ? Git(project, {"worktree", "remove", "--force", worktree}) - : Git(project, {"worktree", "prune"}); - if (r.code != 0) return Failure(r); - if (Git(project, {"rev-parse", "-q", "--verify", "refs/heads/" + branch}).code != 0) - return {}; // already gone - r = Git(project, {"branch", "-D", branch}); - return r.code == 0 ? std::string() : Failure(r); -} diff --git a/src/git_worktree.h b/src/git_worktree.h deleted file mode 100644 index 7e0dfd9..0000000 --- a/src/git_worktree.h +++ /dev/null @@ -1,61 +0,0 @@ -#pragma once -#include - -// Git worktrees for worktree threads: a thread writes in its own checkout on -// branch grit/, so it never writes under the project folder's other -// threads, and the user reviews, merges or discards that branch. Plain -// functions that run git synchronously (argv, no shell). `project` is the -// thread's folder, anywhere inside the user's checkout; `worktree` is the -// root of the thread's checkout. - -// The root of the checkout holding `dir`; empty when it isn't in a git repo -// (or git can't run). -std::string GitTopLevel(const std::string& dir); - -// The branch checked out at `dir`; empty when HEAD is detached. -std::string GitBranch(const std::string& dir); - -// Creates `worktree` on a new branch `branch` from the project's HEAD (its -// committed state). Returns git's error, empty on success. -std::string AddWorktree(const std::string& project, const std::string& worktree, - const std::string& branch); - -// Where a thread of folder `project` works in `worktree`: the same subfolder -// of it as `project` is of the user's checkout. -std::string WorktreeDir(const std::string& project, const std::string& worktree); - -// What discarding the worktree would lose. -struct WorktreeWork { - int unmergedCommits = 0; // on the branch but not in the project's HEAD - int changedFiles = 0; // uncommitted in the worktree, new files included - std::string error; // git failed: the counts are unknown -}; -WorktreeWork UnmergedWork(const std::string& project, const std::string& worktree, - const std::string& branch); - -// Everything the worktree changed (committed and uncommitted, new files -// included) since it branched off the project's HEAD. Stages the worktree's -// files to see new ones; its index is the thread's, not the user's. -struct WorktreeDiff { - std::string stat; // git diff --stat - std::string diff; - std::string error; -}; -WorktreeDiff ReviewWorktree(const std::string& project, const std::string& worktree); - -// Commits the worktree's uncommitted changes on its branch as -// `commitMessage`, then merges the branch into the project's branch with a -// merge commit. Refuses when the project checkout has uncommitted changes or -// isn't on a branch; a conflicting merge is aborted. `message` says what -// happened and, when it stopped, what to do. -struct MergeOutcome { - bool merged = false; - std::string message; -}; -MergeOutcome MergeWorktree(const std::string& project, const std::string& worktree, - const std::string& branch, const std::string& commitMessage); - -// Removes the worktree (with whatever it holds; prunes it when it's gone -// already) and deletes its branch. Returns git's error, empty on success. -std::string RemoveWorktree(const std::string& project, const std::string& worktree, - const std::string& branch); diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp index 65600d6..ce1a61b 100644 --- a/src/mcp_server.cpp +++ b/src/mcp_server.cpp @@ -231,14 +231,7 @@ json MCPServer::HandleRequest(const json& request) { } if (method == "newThread") { if (!cb_.newThread) return err(-32603, "not ready"); - return okResult(cb_.newThread(params.value("cwd", ""), - params.value("worktree", false))); - } - if (method == "worktreeAction") { - if (!cb_.worktreeAction) return err(-32603, "not ready"); - std::string action = params.value("action", ""); - if (action.empty()) return err(-32602, "missing 'action' param"); - return okResult(cb_.worktreeAction(action, params.value("confirm", false))); + return okResult(cb_.newThread(params.value("cwd", ""))); } if (method == "selectThread") { if (!cb_.selectThread) return err(-32603, "not ready"); diff --git a/src/mcp_server.h b/src/mcp_server.h index a5e4d7f..779411f 100644 --- a/src/mcp_server.h +++ b/src/mcp_server.h @@ -55,22 +55,15 @@ struct MCPCallbacks { std::function newSession; // Threads. listThreads returns {threads: [{id, cwd, title, lastUsed, - // loaded, running, waiting, failed, worktree, branch}, ...], activeId}; - // `failed` marks a background failure not yet viewed. newThread adds an - // empty thread in `cwd` (the active folder when empty), in a new git - // worktree when `worktree`, and makes it active; selectThread makes - // thread `id` active. Both return {ok, ...}; every other method acts on - // the active thread. + // loaded, running, waiting, failed}, ...], 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 newThread; std::function selectThread; - // A worktree thread's actions: "review", "merge" or "discard"; `confirm` - // answers the question discard asks before losing work. Returns {ok, - // notice, asked?}: `asked` is that question, `ok` false when it wasn't - // confirmed or the thread has no worktree. - std::function worktreeAction; - // 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. diff --git a/src/project_brief.cpp b/src/project_brief.cpp index a5c29ad..8ccc670 100644 --- a/src/project_brief.cpp +++ b/src/project_brief.cpp @@ -1,14 +1,9 @@ #include "project_brief.h" -#include "streaming_web_request.h" -#include -#include -#include -#include -#include #include #include #include #include +#include #include namespace fs = std::filesystem; @@ -124,84 +119,27 @@ std::string BriefMaterial(const std::string& cwd, const std::string& agents, return out; } -std::optional ShowBriefDialog(wxWindow* parent, const wxString& folder, - const std::string& brief, - const BriefModel& model, - const std::string& material) { - constexpr int kWrap = 600; - wxDialog dlg(parent, wxID_ANY, "Project brief", wxDefaultPosition, - wxSize(kWrap + 40, 520), wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER); - auto* sizer = new wxBoxSizer(wxVERTICAL); - auto* intro = new wxStaticText(&dlg, wxID_ANY, - "What the project in " + folder + " is about: what it is, its layout " - "and conventions, and the current work. Every thread in this folder " - "gets it in its system prompt, so a new thread can help without the " - "history of the others. Leave it empty for no brief."); - intro->Wrap(kWrap); - sizer->Add(intro, 0, wxALL, 12); - auto* text = new wxTextCtrl(&dlg, wxID_ANY, wxString::FromUTF8(brief), - wxDefaultPosition, wxDefaultSize, wxTE_MULTILINE); - sizer->Add(text, 1, wxEXPAND | wxLEFT | wxRIGHT, 12); - auto* status = new wxStaticText(&dlg, wxID_ANY, wxString()); - sizer->Add(status, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 12); - auto* buttons = new wxBoxSizer(wxHORIZONTAL); - auto* generate = new wxButton(&dlg, wxID_ANY, "Generate"); - generate->SetToolTip("Ask " + model.label + " to write the brief from the " - "README, AGENTS.md, the top-level files and the " - "thread titles"); - auto* save = new wxButton(&dlg, wxID_OK, "Save"); - buttons->Add(generate); - buttons->AddStretchSpacer(); - buttons->Add(new wxButton(&dlg, wxID_CANCEL, "Cancel"), 0, wxRIGHT, 8); - buttons->Add(save); - sizer->Add(buttons, 0, wxEXPAND | wxALL, 12); - dlg.SetSizer(sizer); - - // Declared after the dialog, so it is cancelled and joined first; the - // dialog then drops any of its callbacks still queued. - StreamingWebRequest request; - std::string sseBuf; - wxString before; // the text Generate replaced, back if it fails - - auto setStatus = [&](const wxString& msg, bool failed) { - status->SetLabel(msg); - status->SetForegroundColour(failed ? *wxRED : wxNullColour); - status->Wrap(kWrap); - dlg.Layout(); - }; - auto append = [&](std::string_view chunk, bool ended) { - for (const std::string& payload : TakeSseEvents(sseBuf, chunk, ended)) - text->AppendText(wxString::FromUTF8(DeltaText(payload))); +StreamingWebRequest WriteBrief(wxEvtHandler* target, const BriefModel& model, + const std::string& material, + std::function done) { + // 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); }; - auto done = [&](WebResponse resp) { - if (resp.ok) append({}, true); - generate->Enable(); - save->Enable(); - std::string error; - if (!resp.ok) error = resp.error + (sseBuf.empty() ? "" : ": " + ErrorDetail(sseBuf)); - else if (Trimmed(text->GetValue().utf8_string()).empty()) - error = "the model returned no text"; - if (error.empty()) { - setStatus("Written by " + model.label + ". Check it and edit it if " - "needed, then Save.", false); - return; - } - text->SetValue(before); - setStatus(wxString::FromUTF8("\xE2\x9A\xA0 Generation failed: " + error + - ". The previous text is back."), true); - }; - generate->Bind(wxEVT_BUTTON, [&](wxCommandEvent&) { - before = text->GetValue(); - text->Clear(); - sseBuf.clear(); - generate->Disable(); - save->Disable(); - setStatus("Generating with " + model.label + wxString::FromUTF8("\xE2\x80\xA6"), false); - request = StreamingWebRequest(&dlg, BriefRequest(model, material), - [&](std::string_view chunk) { append(chunk, false); }, - done); - }); - - if (dlg.ShowModal() != wxID_OK) return std::nullopt; - return Trimmed(text->GetValue().utf8_string()); + return 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), {}); + }); } diff --git a/src/project_brief.h b/src/project_brief.h index 43b5ef8..ac45eda 100644 --- a/src/project_brief.h +++ b/src/project_brief.h @@ -1,21 +1,23 @@ #pragma once +#include "streaming_web_request.h" +#include #include -#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. Every thread in the folder -// gets it in its system prompt (SessionStore keeps it). +// 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 "Generate" asks: an OpenAI-style chat completions endpoint. +// The model that writes it: an OpenAI-style chat completions endpoint. 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 - wxString label; // shown while generating + wxString label; // named in the notices }; // What the model writes the brief from: the folder's top-level entries, its @@ -23,10 +25,8 @@ struct BriefModel { std::string BriefMaterial(const std::string& cwd, const std::string& agents, const std::vector& threadTitles); -// Modal "Project brief" dialog for `folder`: edit the brief, or let "Generate" -// write it from `material`. Returns the text to save (trimmed, empty for no -// brief) when the user pressed Save, std::nullopt when cancelled. -std::optional ShowBriefDialog(wxWindow* parent, const wxString& folder, - const std::string& brief, - const BriefModel& model, - const std::string& material); +// Asks `model` for the brief. `done` runs on `target` with the brief +// (trimmed), or an empty brief and why it failed. +StreamingWebRequest WriteBrief(wxEvtHandler* target, const BriefModel& model, + const std::string& material, + std::function done); diff --git a/src/session_store.cpp b/src/session_store.cpp index b496584..257c722 100644 --- a/src/session_store.cpp +++ b/src/session_store.cpp @@ -130,8 +130,6 @@ void SessionStore::WriteSessionFile(const Entry& e, j["lastUsed"] = e.lastUsed; if (!e.title.empty()) j["title"] = e.title; if (!e.parent.empty()) j["parent"] = e.parent; - if (!e.worktree.empty()) j["worktree"] = e.worktree; - if (!e.branch.empty()) j["branch"] = e.branch; j["messages"] = history; std::string body = j.dump(2, ' ', false, @@ -168,10 +166,6 @@ bool SessionStore::SaveBrief(const std::string& cwd, const std::string& text) co return AtomicWrite(BriefPath(cwd), text); } -std::string SessionStore::WorktreePath(const std::string& id) const { - return root_ + "/worktrees/" + id; -} - void SessionStore::SetLastActive(const std::string& cwd, const std::string& threadId) { lastActiveCwd_ = cwd; lastActiveThread_ = threadId; @@ -200,8 +194,6 @@ void SessionStore::WriteIndex() const { j["sessions"].push_back(std::move(row)); } else { if (!e.parent.empty()) row["parent"] = e.parent; - if (!e.worktree.empty()) row["worktree"] = e.worktree; - if (!e.branch.empty()) row["branch"] = e.branch; j["threads"].push_back(std::move(row)); } } @@ -225,8 +217,6 @@ void SessionStore::ReadIndex() { ent.lastUsed = e.value("lastUsed", std::string{}); ent.title = e.value("title", std::string{}); ent.parent = e.value("parent", std::string{}); - ent.worktree = e.value("worktree", std::string{}); - ent.branch = e.value("branch", std::string{}); if (ent.cwd.empty()) return; // Backfill id if older index lacks it. if (ent.id.empty()) ent.id = IdForCwd(ent.cwd); @@ -252,7 +242,7 @@ void SessionStore::ReadIndex() { // 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, worktree, branch) are enough; the messages are skipped while parsing. + // 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; diff --git a/src/session_store.h b/src/session_store.h index c140629..02d7d8d 100644 --- a/src/session_store.h +++ b/src/session_store.h @@ -13,9 +13,6 @@ // 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 -// Worktree threads record their checkout and branch in optional `worktree` -// and `branch` fields (older versions ignore them); the checkouts live in -// ~/.local/share/gritcode/worktrees// // Writes are atomic (temp file then rename) so a crash mid-write can't // corrupt the index or any session file. class SessionStore { @@ -26,10 +23,6 @@ class SessionStore { 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 - // A worktree thread's own checkout (root) and its branch; both empty - // for threads working in the folder itself. - std::string worktree; - std::string branch; }; // Loads the index from disk, creating storage directories if needed. @@ -77,10 +70,6 @@ class SessionStore { std::string LoadBrief(const std::string& cwd) const; bool SaveBrief(const std::string& cwd, const std::string& text) const; - // Where worktree thread `id` gets its checkout: worktrees/ in the - // data dir, outside the user's repo. - std::string WorktreePath(const std::string& id) const; - // 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); diff --git a/src/thread_list.h b/src/thread_list.h index 115a77a..0159ddb 100644 --- a/src/thread_list.h +++ b/src/thread_list.h @@ -10,7 +10,6 @@ struct ThreadRow { wxString title; wxString detail; // the current step while running, else when last used State state = State::Done; - std::string branch; // a worktree thread's branch, else empty bool child = false; // a subagent, indented under its parent }; From eabd6b3c596c14e9a6307280cd825a0ec0308332 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 13:07:40 +0100 Subject: [PATCH 26/33] threads: write the project brief with the selected model's provider DeepSeek users get DeepSeek Flash, free-model users the free model, and Claude users Sonnet through their Claude Code CLI (print mode, no tools, no saved session, material on stdin), so a folder's README and file names never go to a provider the user didn't pick. The brief test checks the Claude case with a stand-in CLI and that nothing reached the HTTP model. Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 14 +++++++---- scripts/smoke/brief.py | 47 ++++++++++++++++++++++++++++++++++--- scripts/smoke/fake_model.py | 2 ++ src/chat_frame.cpp | 40 +++++++++++++++++-------------- src/chat_frame.h | 6 +++-- src/project_brief.cpp | 46 ++++++++++++++++++++++++++++++++---- src/project_brief.h | 21 ++++++++++++----- 7 files changed, 140 insertions(+), 36 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index ffdddf1..0eee6ad 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -104,10 +104,16 @@ 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, DeepSeek Flash (cheap and fast; the free model -without a key; Claude's CLI doesn't fit one request) writes it 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. +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 diff --git a/scripts/smoke/brief.py b/scripts/smoke/brief.py index 1afd388..f3d494f 100644 --- a/scripts/smoke/brief.py +++ b/scripts/smoke/brief.py @@ -9,7 +9,8 @@ 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. +(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 @@ -24,7 +25,7 @@ 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 +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") @@ -67,6 +68,24 @@ def wait_for(cond, timeout=20): # 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")) @@ -81,7 +100,8 @@ def wait_for(cond, timeout=20): 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"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). @@ -138,6 +158,27 @@ def wait_for(cond, timeout=20): "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) diff --git a/scripts/smoke/fake_model.py b/scripts/smoke/fake_model.py index 4694076..00570ab 100755 --- a/scripts/smoke/fake_model.py +++ b/scripts/smoke/fake_model.py @@ -79,10 +79,12 @@ def sse(delta, crlf, unterminated): 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 diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index dbf8609..e0d6f24 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -460,18 +460,17 @@ constexpr const char* kSubagentNote = "inspect (grep, git log, running tests). Nobody can answer questions, so " "state your assumptions in the report."; -// Model that writes a project brief: DeepSeek Flash when there is a key (it -// is cheap and fast), otherwise the free model. Claude runs through its CLI, -// which doesn't fit a single request. -ModelRoute BriefRoute(const std::vector& remoteModels) { - if (!Preferences::HasApiKey(Preferences::Provider::DeepSeek)) - return RouteForIndex(kModelKiloFree, remoteModels); - const std::vector& ids = DeepseekModels(remoteModels); - auto flash = std::ranges::find_if(ids, [](const std::string& id) { - return id.find("flash") != std::string::npos; - }); - return RouteForIndex(flash == ids.end() ? 1 : 1 + (int)(flash - ids.begin()), - remoteModels); +// 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 @@ -2706,7 +2705,8 @@ bool ChatFrame::WriteProjectBrief(const std::string& cwd) { if (job->running) return false; std::vector titles; for (const FolderThread& f : FolderThreads(cwd)) titles.push_back(f.title); - const ModelRoute route = BriefRoute(remoteModels_); + const ModelRoute route = RouteForIndex(BriefModelIndex(currentModelIndex_, remoteModels_), + remoteModels_); const BriefModel model{ .url = route.url, .model = route.model, @@ -2714,14 +2714,20 @@ bool ChatFrame::WriteProjectBrief(const std::string& cwd) { ? 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; - job->request = WriteBrief(this, model, BriefMaterial(cwd, LoadAgentsInstructions(cwd), titles), - [this, cwd](std::string brief, std::string error) { - OnBriefWritten(cwd, std::move(brief), std::move(error)); - }); + 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; } diff --git a/src/chat_frame.h b/src/chat_frame.h index f5c9866..aeea62c 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -18,6 +18,7 @@ #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" @@ -538,13 +539,14 @@ class ChatFrame : public wxFrame { 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, and its later new threads reuse it. + // 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; - StreamingWebRequest request; + BriefRun run; }; std::deque briefJobs_; // Starts writing `cwd`'s brief; false when a job for it already runs. diff --git a/src/project_brief.cpp b/src/project_brief.cpp index 8ccc670..ac12bbc 100644 --- a/src/project_brief.cpp +++ b/src/project_brief.cpp @@ -119,9 +119,46 @@ std::string BriefMaterial(const std::string& cwd, const std::string& agents, return out; } -StreamingWebRequest WriteBrief(wxEvtHandler* target, const BriefModel& model, - const std::string& material, - std::function done) { +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(); @@ -129,7 +166,7 @@ StreamingWebRequest WriteBrief(wxEvtHandler* target, const BriefModel& model, for (const std::string& payload : TakeSseEvents(*sse, chunk, ended)) *text += DeltaText(payload); }; - return StreamingWebRequest( + run.request = StreamingWebRequest( target, BriefRequest(model, material), [take](std::string_view chunk) { take(chunk, false); }, [take, sse, text, done = std::move(done)](WebResponse resp) { @@ -142,4 +179,5 @@ StreamingWebRequest WriteBrief(wxEvtHandler* target, const BriefModel& model, 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 index ac45eda..33b5c5a 100644 --- a/src/project_brief.h +++ b/src/project_brief.h @@ -1,4 +1,5 @@ #pragma once +#include "claude_agent.h" #include "streaming_web_request.h" #include #include @@ -11,22 +12,30 @@ // 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. +// 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. `done` runs on `target` with the brief -// (trimmed), or an empty brief and why it failed. -StreamingWebRequest WriteBrief(wxEvtHandler* target, const BriefModel& model, - const std::string& material, - std::function done); +// 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); From ff29d39066d430c3c507c52a3c50b7a5a79ceed8 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 13:31:11 +0100 Subject: [PATCH 27/33] docs: point the worktree note at the rewritten merge Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index 0eee6ad..d40c897 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -180,7 +180,7 @@ a running one offers "Stop". The agent map and the decision tray come later (see 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 d48a624, reverted); comes back from there. + 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. From bb8114d17f91932e53600bd7d007fd88c12414d5 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 13:52:09 +0100 Subject: [PATCH 28/33] threads: run turns in one folder at the same time Drop the one-running-turn-per-folder lock and its waiting state: a turn starts as soon as it is sent, whatever else runs in the folder. Subagents stay read-only. Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 18 +++++----- scripts/smoke/history.py | 2 +- scripts/smoke/subagents.py | 2 +- scripts/smoke/thread_pane.py | 7 ++-- scripts/smoke/threads.py | 51 +++++++++++++-------------- src/chat_frame.cpp | 68 +++--------------------------------- src/chat_frame.h | 16 ++------- src/mcp_server.h | 2 +- src/thread_list.cpp | 3 -- src/thread_list.h | 2 +- 10 files changed, 45 insertions(+), 126 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index d40c897..119300f 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -41,12 +41,11 @@ bash `chdir`s in the forked child. The process-wide `ChdirToCwd` goes away. ## Write safety -One running turn per folder. Starting a turn while another thread in the same -folder is running queues it (shown as "waiting for "). Real parallel -writing in one folder comes later with worktrees. +Threads in one folder run at the same time, with no lock: two threads +editing the same file can overwrite each other, and giving them separate work +is up to the user. Isolating them (worktrees) comes later. -A thread and its subagents count as one owner of the folder, so a subagent -never waits for its own parent. Subagents are read-only: `write_file`, +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 @@ -150,10 +149,9 @@ 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, ○ waiting for the folder, ⚠ failed until viewed) with the title, +(● 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", "waiting for -"), after a failure its notice, otherwise when it was last used. The +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 @@ -163,7 +161,7 @@ a running one offers "Stop". The agent map and the decision tray come later (see ## Steps -1. Threads: struct, multi-thread runtime, store, per-thread cwd, write queue, +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) @@ -176,7 +174,7 @@ a running one offers "Stop". The agent map and the decision tray come later (see 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, waiting, failed, done); the + 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 diff --git a/scripts/smoke/history.py b/scripts/smoke/history.py index 8bef7a4..ea2920c 100644 --- a/scripts/smoke/history.py +++ b/scripts/smoke/history.py @@ -148,7 +148,7 @@ def stdio_search(args): ["grit_history_search", {"query": "zebracorn", "limit": 20}]])) end = time.time() + 60 while time.time() < end and (time.sleep(0.5) or any( - t["running"] or t["waiting"] for t in g.list_threads()["threads"])): + 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") diff --git a/scripts/smoke/subagents.py b/scripts/smoke/subagents.py index 2666d8b..2eef3d3 100644 --- a/scripts/smoke/subagents.py +++ b/scripts/smoke/subagents.py @@ -101,7 +101,7 @@ def children(g, parent): def wait_idle(g, timeout=60): end = time.time() + timeout while time.time() < end: - if not any(t["running"] or t["waiting"] for t in threads(g).values()): + if not any(t["running"] for t in threads(g).values()): return True time.sleep(0.5) return False diff --git a/scripts/smoke/thread_pane.py b/scripts/smoke/thread_pane.py index a988b00..548e471 100644 --- a/scripts/smoke/thread_pane.py +++ b/scripts/smoke/thread_pane.py @@ -118,11 +118,8 @@ def day(iso): b = g.new_thread(P)["threadId"] g.send("Say bye.") time.sleep(0.3) - if any(t["id"] == a and t["running"] for t in g.list_threads()["threads"]): - detail = rows(g)[b]["detail"] - check(detail.startswith("waiting for "), f"a queued row says what it waits for ({detail})") - else: - print("skip the first turn already ended; no queued row to check") + 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() diff --git a/scripts/smoke/threads.py b/scripts/smoke/threads.py index a34579b..ab12aec 100755 --- a/scripts/smoke/threads.py +++ b/scripts/smoke/threads.py @@ -75,7 +75,7 @@ def threads(g): def wait_idle(g, timeout=240): end = time.time() + timeout while time.time() < end: - if not any(t["running"] or t["waiting"] for t in threads(g).values()): + if not any(t["running"] for t in threads(g).values()): return True time.sleep(1) return False @@ -125,16 +125,16 @@ def bash(cmd): b2 = g.new_thread(B)["threadId"] g.send(bash("date +%s.%N > second_start")) now = threads(g) -check(now[b]["running"] and now[b2]["waiting"], "the second thread waits") -check(any("Waiting for" in blk["preview"] for blk in g.blocks()), "waiting notice shown") +check(now[b]["running"] and now[b2]["running"], "both threads run at once") 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 after the first finished") +check(bool(first and second) and float(second) < float(first), + "the second ran while the first was still going") print("-- opening a folder") g.switch_session(A) -check(g.switch_session(B).get("threadId") == b2, "it shows the folder's most recent thread") +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") @@ -147,32 +147,29 @@ def bash(cmd): check(last["role"] == "assistant" and last["content"].startswith("Results:"), f"[{marker}] the reply after it arrived") -print("-- a turn that fails in the background") -g.select_thread(b) -g.send(bash("sleep 8")) # keeps the folder busy -x = g.new_thread(B)["threadId"] -g.set_model(1) # a DeepSeek model: needs a key, and this HOME has none -g.send("remember this prompt") -check(threads(g)[x]["waiting"], "it waits for the folder") -g.select_thread(b) -g.cancel() # b ends; x starts in the background and fails without a key -end = time.time() + 60 -while time.time() < end and threads(g)[x]["waiting"]: - time.sleep(0.5) -g.set_model(0) -now = threads(g) -check(now[x]["failed"] and not now[x]["running"], "it failed in the background") -g.select_thread(x) -check(any("No API key" in blk["preview"] for blk in g.blocks()), "its notice shows when viewed") -check(g.status()["input"] == "remember this prompt", "its prompt is back in the input") -check(not threads(g)[x]["failed"], "it is no longer marked as failed") +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} <= now.keys(), "threads restored") -check(g.status()["threadId"] == x, "last active thread restored") +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") diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index e0d6f24..567f2d6 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -1567,7 +1567,6 @@ ChatFrame::ChatFrame() return { {"threadId", t.id}, {"streaming", t.streaming}, - {"waiting", t.waiting}, {"toolIter", t.toolIter}, {"historyLen", (int)t.history.size()}, {"pendingQueue", std::move(queue)}, @@ -1653,8 +1652,7 @@ ChatFrame::ChatFrame() arr.push_back({ {"id", id}, {"cwd", cwd}, {"title", title}, {"parent", parent}, {"lastUsed", lastUsed}, {"loaded", t != nullptr}, - {"running", t && t->streaming && !t->waiting}, - {"waiting", t && t->waiting}, + {"running", t && t->streaming}, {"failed", t && t->failureNotice.has_value()}, }); }; @@ -2242,15 +2240,6 @@ void ChatFrame::OnCharHook(wxKeyEvent& e) { } void ChatFrame::RequestCancel(Thread& t) { - // A turn still waiting for its folder hasn't sent anything: drop its - // user message, as the error path does, and end it. - if (t.waiting) { - t.waiting = false; - if (!t.history.empty() && IsUserTurn(t.history.back())) t.history.pop_back(); - RenderErrorBlock(t, "Cancelled."); - FinalizeTurn(t, true); - return; - } // 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); @@ -2370,7 +2359,7 @@ std::vector ChatFrame::FolderRows() { ThreadRow r{.id = t.id, .title = ThreadLabel(t.id, t.cwd, t.title), .detail = LastUsedLabel(t.lastUsed)}; if (t.streaming) { - r.state = t.waiting ? ThreadRow::State::Waiting : ThreadRow::State::Running; + r.state = ThreadRow::State::Running; r.detail = CurrentStep(t); } else if (t.failureNotice && !Shown(t)) { // until viewed r.state = ThreadRow::State::Failed; @@ -2404,11 +2393,6 @@ std::vector ChatFrame::FolderRows() { } wxString ChatFrame::CurrentStep(Thread& t) { - if (t.waiting) { - const Thread* r = RunningIn(t.cwd, t); - return "waiting for " + (r ? ThreadLabel(r->id, r->cwd, r->title) - : wxString("the folder")); - } 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 @@ -2470,7 +2454,7 @@ 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 || r.state == ThreadRow::State::Waiting; + return r.state == ThreadRow::State::Running; }); if (!running) threadPaneTimer_.Stop(); else if (!threadPaneTimer_.IsRunning()) threadPaneTimer_.Start(1000); @@ -2880,7 +2864,6 @@ void ChatFrame::RestoreCanvasFromHistory() { t.failureNotice.reset(); } if (t.streaming) { - if (t.waiting) RenderWaitingNotice(t); if (t.thinkingEmitted) RenderThinkingBlock(t, wxString::FromUTF8(t.activeReasoning)); else UpdateLiveThinking(t); if (t.mdStream) { @@ -4402,53 +4385,14 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, UpdateQueueUI(); // Send becomes "Add", chip row stays visible if non-empty. } - // One running turn per folder: until the other thread there finishes, - // this one waits (StartNextWaiting starts it). - if (RunningIn(t.cwd, t)) { - t.waiting = true; - RenderWaitingNotice(t); - } else { - StartCompletion(t); - } + StartCompletion(t); RefreshSessionChoice(); // marks the thread as running } -ChatFrame::Thread* ChatFrame::RunningIn(const std::string& cwd, const Thread& self) { - // A thread and its subagents count as one owner of the folder (the - // subagents are read-only, see IsWriteTool). - auto family = [](const Thread& t) -> const std::string& { - return t.parent.empty() ? t.id : t.parent; - }; - for (Thread& t : threads_) { - if (&t != &self && t.cwd == cwd && t.streaming && !t.waiting - && family(t) != family(self)) - return &t; - } - return nullptr; -} - int ChatFrame::ModelIndexFor(const Thread& t) const { return t.parent.empty() ? currentModelIndex_ : t.turnModelIndex; } -void ChatFrame::StartNextWaiting(const std::string& cwd) { - for (Thread& t : threads_) { - if (!t.waiting || t.cwd != cwd) continue; - if (RunningIn(cwd, t)) return; - t.waiting = false; - StartCompletion(t); - return; - } -} - -void ChatFrame::RenderWaitingNotice(Thread& t) { - const Thread* other = RunningIn(t.cwd, t); - if (!other) return; - RenderErrorBlock(t, "Waiting for " + ThreadLabel(other->id, other->cwd, other->title) - + wxString::FromUTF8(" to finish\xE2\x80\xA6")); -} - - void ChatFrame::EnqueueMessage(Thread& t, const wxString& text) { QueuedMessage q; q.text = text.ToStdString(wxConvUTF8); @@ -5274,7 +5218,6 @@ void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { t.liveThinkingIdx = -1; if (Shown(t)) canvas_->SetThinking(false); t.streaming = false; - t.waiting = false; t.activeAssistantText.clear(); t.activeReasoning.clear(); t.activeToolCalls.clear(); @@ -5288,9 +5231,6 @@ void ChatFrame::FinalizeTurn(Thread& t, bool wasCancelledOrError) { // label shows up. RefreshSessionChoice(); - // The folder is free: a thread waiting for it goes before this thread's - // own queue, which would otherwise keep the folder to itself. - StartNextWaiting(t.cwd); if (!t.parentToolId.empty()) ReportToParent(t, wasCancelledOrError); // Natural completion: auto-dispatch the next queued message so back-to- diff --git a/src/chat_frame.h b/src/chat_frame.h index aeea62c..3b84424 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -142,11 +142,8 @@ class ChatFrame : public wxFrame { std::vector history; std::string lastAgentsContent; // AGENTS.md text currently in history[0] - // True from StartTurn until FinalizeTurn, including while `waiting`. + // True from StartTurn until FinalizeTurn. bool streaming = false; - // The turn is queued behind another thread running in the same folder - // and starts when that one finishes (see StartNextWaiting). - bool waiting = false; // 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. @@ -373,8 +370,7 @@ class ChatFrame : public wxFrame { void RebuildChips(); // Cancel whatever the thread has in flight: HTTP stream, tool batch, or - // both, or a turn still waiting for its folder. Safe to call when nothing - // is running. Wired to Escape. + // both. Safe to call when nothing is running. Wired to Escape. void RequestCancel(Thread& t); void OnSessionChoice(wxCommandEvent&); void OnModelChoice(wxCommandEvent&); @@ -513,7 +509,7 @@ class ChatFrame : public wxFrame { // 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 — - // followed by the in-flight turn (partial text, waiting notice). + // followed by the in-flight turn (partial text). void RestoreCanvasFromHistory(); // System prompt for `t`: the static base, AGENTS.md (recorded in @@ -652,10 +648,6 @@ class ChatFrame : public wxFrame { void StartTurn(Thread& t, const wxString& userText, std::vector images = {}, const std::string& hiddenInstructions = std::string()); - // The thread (other than `self`) running a turn in `cwd`, if any. - Thread* RunningIn(const std::string& cwd, const Thread& self); - // Start the first turn waiting for `cwd`, unless one is running there. - void StartNextWaiting(const std::string& cwd); void EnqueueMessage(Thread& t, const wxString& text); void LogDebug(const std::string& line); void OpenDebugWindow(); @@ -739,8 +731,6 @@ class ChatFrame : public wxFrame { const std::string& result, const std::string& childId = std::string()); void RenderErrorBlock(Thread& t, const wxString& msg); - // "Waiting for to finish" while t.waiting. - void RenderWaitingNotice(Thread& t); // 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); diff --git a/src/mcp_server.h b/src/mcp_server.h index 779411f..d562bbd 100644 --- a/src/mcp_server.h +++ b/src/mcp_server.h @@ -55,7 +55,7 @@ struct MCPCallbacks { std::function newSession; // Threads. listThreads returns {threads: [{id, cwd, title, lastUsed, - // loaded, running, waiting, failed}, ...], activeId}; `failed` marks a + // loaded, running, failed}, ...], 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 diff --git a/src/thread_list.cpp b/src/thread_list.cpp index d1652a9..f978358 100644 --- a/src/thread_list.cpp +++ b/src/thread_list.cpp @@ -50,9 +50,6 @@ void ThreadList::OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const { } else if (r.state == Running) { dc.SetTextForeground(selected ? fg : omarchy::Readable(accent, bg, 3.0)); dc.DrawText(wxString::FromUTF8("\xE2\x97\x8F"), x, y); // "●" - } else if (r.state == Waiting) { - dc.SetTextForeground(dim); - dc.DrawText(wxString::FromUTF8("\xE2\x97\x8B"), x, y); // "○" } x += markW; const int textW = rect.GetRight() - pad - x; diff --git a/src/thread_list.h b/src/thread_list.h index 0159ddb..3448df4 100644 --- a/src/thread_list.h +++ b/src/thread_list.h @@ -5,7 +5,7 @@ // One row of the thread pane. struct ThreadRow { - enum class State { Done, Running, Waiting, Failed }; + enum class State { Done, Running, Failed }; std::string id; wxString title; wxString detail; // the current step while running, else when last used From 348ae38b98e102ff271e5ddf9a006df48deb3b2a Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 13:57:10 +0100 Subject: [PATCH 29/33] threads: tell agents how to share a checkout, and when to use a worktree A top-level thread in a git repo gets rules in its system prompt: don't move the shared checkout's branch or index, work in place by default, and take branch work, or bigger work while another thread is busy there, into a git worktree under the data dir. When a turn starts while other threads run in the same checkout, the user message gets a note naming them; the model sees it after the message and the canvas shows it. Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 21 ++++++++++++-- scripts/smoke/threads.py | 15 +++++++++- src/chat_frame.cpp | 63 ++++++++++++++++++++++++++++++++++++++++ src/chat_frame.h | 6 ++-- src/session_store.h | 4 +++ 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index 119300f..0c7f8b7 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -41,9 +41,24 @@ 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: two threads -editing the same file can overwrite each other, and giving them separate work -is up to the user. Isolating them (worktrees) comes later. +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). It is stored as the message's + `note`, added after the message for the model (`[gritcode: ...]`) and shown + as a notice in the canvas, so the user sees what the agent was told. 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, diff --git a/scripts/smoke/threads.py b/scripts/smoke/threads.py index ab12aec..ac33322 100755 --- a/scripts/smoke/threads.py +++ b/scripts/smoke/threads.py @@ -23,7 +23,7 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path[:0] = [HERE, os.path.join(HERE, "..")] -from fake_model import serve # noqa: E402 +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") @@ -101,6 +101,7 @@ def bash(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) @@ -126,6 +127,18 @@ def bash(cmd): 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(any("Also working in " + B in blk["preview"] for blk in g.blocks()), + "the second thread shows that the first is working there") +if not LIVE: + end = time.time() + 10 + sent = [] + while time.time() < end and not sent: + sent = [m for m in Handler.requests if "second_start" in json.dumps(m[-1])] + time.sleep(0.2) + check(bool(sent) and f"[gritcode: Also working in {B} right now:" in sent[0][-1]["content"], + "and its model is told") + check(bool(sent) and "git worktree add -b" in sent[0][0]["content"], + "the system prompt of a thread in a git repo says when to use a worktree") 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), diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 567f2d6..5042ec5 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -719,6 +719,39 @@ 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) 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; @@ -2673,6 +2706,10 @@ std::string ChatFrame::FolderContext(const Thread& t) const { "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; } @@ -2790,6 +2827,8 @@ void ChatFrame::RestoreCanvasFromHistory() { ub.runs.push_back(r); ub.visibleText = text; canvas_->AddBlock(std::move(ub)); + if (std::string note = StringField(m, "note"); !note.empty()) + canvas_->AddBlock(NoticeBlock(wxString::FromUTF8(note))); if (m.contains("images") && m["images"].is_array()) { for (const auto& img : m["images"]) { @@ -4365,6 +4404,24 @@ void ChatFrame::StartTurn(Thread& t, 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), and the canvas shows it. + 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() + "\""; + } + if (!others.empty()) { + const std::string note = "Also working in " + checkout + " right now: " + others + "."; + userMsg["note"] = note; + if (Shown(t)) canvas_->AddBlock(NoticeBlock(wxString::FromUTF8(note))); + } + } if (!images.empty()) { nlohmann::json arr = nlohmann::json::array(); for (const auto& pi : images) { @@ -4450,6 +4507,10 @@ nlohmann::json ChatFrame::BuildModelView(const Thread& t) const { 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); @@ -6123,6 +6184,8 @@ void ChatFrame::StartClaudeTurn(Thread& t, bool fresh) { 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"]) { diff --git a/src/chat_frame.h b/src/chat_frame.h index 3b84424..5c79dd8 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -529,9 +529,9 @@ class ChatFrame : public wxFrame { }; // The folder's titled top-level threads, most recent first. std::vector FolderThreads(const std::string& cwd) const; - // The folder's project brief and a short list of its other threads, so a - // new thread knows what the project is and that parallel work exists. - // Empty when the folder has neither. + // 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 diff --git a/src/session_store.h b/src/session_store.h index 02d7d8d..974938a 100644 --- a/src/session_store.h +++ b/src/session_store.h @@ -70,6 +70,10 @@ class SessionStore { 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"; } + // 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); From 8e19a4deebce811a92074c3827f662da129a8199 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 14:09:43 +0100 Subject: [PATCH 30/33] threads: keep the shared-checkout note out of the chat, send it only on change Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 10 ++++++---- scripts/smoke/threads.py | 38 +++++++++++++++++++++++++++++--------- src/chat_frame.cpp | 23 ++++++++++++++--------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index 0c7f8b7..e96e6f3 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -55,10 +55,12 @@ about worktrees: 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). It is stored as the message's - `note`, added after the message for the model (`[gritcode: ...]`) and shown - as a notice in the canvas, so the user sees what the agent was told. No - note, no reason for a worktree, so most work makes none. + 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, diff --git a/scripts/smoke/threads.py b/scripts/smoke/threads.py index ac33322..e08d2de 100755 --- a/scripts/smoke/threads.py +++ b/scripts/smoke/threads.py @@ -127,22 +127,42 @@ def bash(cmd): 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(any("Also working in " + B in blk["preview"] for blk in g.blocks()), - "the second thread shows that the first is working there") -if not LIVE: +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 - sent = [] - while time.time() < end and not sent: - sent = [m for m in Handler.requests if "second_start" in json.dumps(m[-1])] + 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) - check(bool(sent) and f"[gritcode: Also working in {B} right now:" in sent[0][-1]["content"], - "and its model is told") - check(bool(sent) and "git worktree add -b" in sent[0][0]["content"], + 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) diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 5042ec5..e8ec6a6 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -735,7 +735,8 @@ std::string SharedCheckoutNote(const std::string& repo, const std::string& workt "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) and your change is more than a small edit: `git worktree " + "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 " @@ -2827,8 +2828,6 @@ void ChatFrame::RestoreCanvasFromHistory() { ub.runs.push_back(r); ub.visibleText = text; canvas_->AddBlock(std::move(ub)); - if (std::string note = StringField(m, "note"); !note.empty()) - canvas_->AddBlock(NoticeBlock(wxString::FromUTF8(note))); if (m.contains("images") && m["images"].is_array()) { for (const auto& img : m["images"]) { @@ -4406,7 +4405,7 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, }; // 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), and the canvas shows it. + // 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; @@ -4416,11 +4415,17 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, others += (others.empty() ? "\"" : ", \"") + ThreadLabel(o.id, o.cwd, o.title).utf8_string() + "\""; } - if (!others.empty()) { - const std::string note = "Also working in " + checkout + " right now: " + others + "."; - userMsg["note"] = note; - if (Shown(t)) canvas_->AddBlock(NoticeBlock(wxString::FromUTF8(note))); - } + // 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(); From ea75259d3f0a762c5444ab25068ed13998eb6b9c Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 14:29:11 +0100 Subject: [PATCH 31/33] threads: a draft per thread, a Play run per folder The input's text and attached images stay with the thread you switch away from and come back with it. Play keeps one run per folder, so Play in one project no longer stops another's, and its blocks stay with the thread Play was pressed in, put back in place when the canvas is rebuilt; they are still display-only. Also: a DeepSeek key from GRIT_DEEPSEEK_API_KEY for headless test instances, and the control API lists the model dropdown and can set the input. Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 8 +- scripts/smoke/play.py | 102 +++++++++++++++++++++++++ src/chat_frame.cpp | 165 +++++++++++++++++++++++++---------------- src/chat_frame.h | 30 ++++++-- src/mcp_server.cpp | 4 + src/mcp_server.h | 3 + src/preferences.cpp | 7 ++ 7 files changed, 250 insertions(+), 69 deletions(-) create mode 100644 scripts/smoke/play.py diff --git a/docs/design/threads.md b/docs/design/threads.md index e96e6f3..f82c220 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -173,7 +173,13 @@ 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 agent map and the decision tray come later (see +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 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/src/chat_frame.cpp b/src/chat_frame.cpp index e8ec6a6..3042646 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -541,6 +541,42 @@ 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); @@ -1721,10 +1757,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()}, @@ -1878,6 +1919,12 @@ 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 (Active().streaming) return {{"started", false}, {"reason", "streaming"}}; @@ -2121,16 +2168,7 @@ ChatFrame::~ChatFrame() { delete themeWatcher_; themeWatcher_ = nullptr; themeTimer_.Stop(); - 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(); } @@ -2299,7 +2337,7 @@ void ChatFrame::RequestCancel(Thread& t) { 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. @@ -2601,9 +2639,18 @@ bool ChatFrame::ShowThread(const std::string& id) { } // The thread we leave keeps running; its canvas indices are now stale. - Active().liveThinkingIdx = -1; + // 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(); @@ -2803,10 +2850,17 @@ void ChatFrame::RestoreCanvasFromHistory() { 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 < 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 @@ -2895,6 +2949,7 @@ void ChatFrame::RestoreCanvasFromHistory() { // 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) { @@ -3015,6 +3070,17 @@ 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 (Active().streaming) return; auto cfg = RunConfigStore::Get(activeCwd_); @@ -3032,24 +3098,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 @@ -3072,11 +3135,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(Active(), "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 { @@ -5421,38 +5486,7 @@ void ChatFrame::RenderToolBlock(Thread& t, const std::string& name, const std::string& result, const std::string& childId) { if (!Shown(t)) return; - // 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; - canvas_->AddBlock(std::move(b)); + canvas_->AddBlock(ToolBlock(name, argsJson, result)); if (!childId.empty()) canvas_->AddBlock(ThreadLinkBlock("Subagent thread: ", childId, ThreadLabelOf(childId))); } @@ -5461,6 +5495,11 @@ void ChatFrame::RenderErrorBlock(Thread& t, const wxString& msg) { if (Shown(t)) 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::ShowTurnFailure(Thread& t, Block notice, bool promptRolledBack) { t.failure = notice.visibleText.utf8_string(); if (Shown(t)) { diff --git a/src/chat_frame.h b/src/chat_frame.h index 5c79dd8..213add9 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -144,6 +144,17 @@ class ChatFrame : public wxFrame { // True from StartTurn until FinalizeTurn. bool streaming = false; + // 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. @@ -307,11 +318,17 @@ class ChatFrame : public wxFrame { int currentModelIndex_ = 0; std::vector remoteModels_; - // 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_; + // 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::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 @@ -731,6 +748,9 @@ class ChatFrame : public wxFrame { 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); diff --git a/src/mcp_server.cpp b/src/mcp_server.cpp index ce1a61b..860606f 100644 --- a/src/mcp_server.cpp +++ b/src/mcp_server.cpp @@ -190,6 +190,10 @@ 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()); diff --git a/src/mcp_server.h b/src/mcp_server.h index d562bbd..5dbe40d 100644 --- a/src/mcp_server.h +++ b/src/mcp_server.h @@ -100,6 +100,9 @@ struct MCPCallbacks { // {started: false, reason: "streaming"} while a turn is in flight. std::function play; + // 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; diff --git a/src/preferences.cpp b/src/preferences.cpp index 5c36698..78cce14 100644 --- a/src/preferences.cpp +++ b/src/preferences.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #if wxUSE_SECRETSTORE #include #else @@ -146,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(); From 373b4a46b29970a527d5ac0f5341448b21aa7046 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 14:44:20 +0100 Subject: [PATCH 32/33] threads: the session dropdown's mark means something you haven't seen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A folder's mark is now ⚠ when a thread there failed unseen and ● when one is working or finished since it was last viewed; the thread on screen never counts, so the closed dropdown tells you about the folder's other threads. Subagents report through their parent, a turn you stopped leaves nothing to see. The Threads list marks the same threads. Co-Authored-By: Claude Opus 5.5 --- docs/design/threads.md | 9 +++++++-- scripts/smoke/thread_pane.py | 31 +++++++++++++++++++++++++++++++ src/chat_frame.cpp | 31 +++++++++++++++++++++++-------- src/chat_frame.h | 5 +++++ src/mcp_server.h | 2 +- src/thread_list.cpp | 2 +- src/thread_list.h | 3 ++- 7 files changed, 70 insertions(+), 13 deletions(-) diff --git a/docs/design/threads.md b/docs/design/threads.md index f82c220..f20b1b5 100644 --- a/docs/design/threads.md +++ b/docs/design/threads.md @@ -158,8 +158,13 @@ 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, a ● marking one with a -running thread. Picking a folder opens its most recently used thread. +(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 diff --git a/scripts/smoke/thread_pane.py b/scripts/smoke/thread_pane.py index 548e471..bc67ec3 100644 --- a/scripts/smoke/thread_pane.py +++ b/scripts/smoke/thread_pane.py @@ -127,6 +127,37 @@ def day(iso): 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"}) diff --git a/src/chat_frame.cpp b/src/chat_frame.cpp index 3042646..362c361 100644 --- a/src/chat_frame.cpp +++ b/src/chat_frame.cpp @@ -1724,6 +1724,7 @@ ChatFrame::ChatFrame() {"lastUsed", lastUsed}, {"loaded", t != nullptr}, {"running", t && t->streaming}, {"failed", t && t->failureNotice.has_value()}, + {"unseen", t && t->unseen}, }); }; for (const Thread& t : threads_) @@ -1938,7 +1939,8 @@ ChatFrame::ChatFrame() 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}}); + {"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()); @@ -2319,6 +2321,7 @@ void ChatFrame::RequestCancel(Thread& t) { RequestCancel(*p); return; } + if (t.streaming) t.stopRequested = true; // Idempotent on a finished request — Cancel just sets an atomic. t.request.Cancel(); // A Claude turn gets SIGINT, so Claude Code ends it cleanly. @@ -2436,6 +2439,8 @@ std::vector ChatFrame::FolderRows() { } 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; } rows.push_back({std::move(r), t.parent, t.lastUsed}); } @@ -2508,13 +2513,20 @@ void ChatFrame::RefreshSessionChoice() { sessionCwds_.insert(sessionCwds_.begin(), activeCwd_); sessionChoice_->Clear(); sessionChoice_->Append(wxString::FromUTF8("New Session\xE2\x80\xA6")); - for (const std::string& cwd : sessionCwds_) { - const bool running = std::ranges::any_of(threads_, [&](const Thread& t) { - return t.cwd == cwd && t.streaming; - }); - sessionChoice_->Append((running ? wxString::FromUTF8("\xE2\x97\x8F ") : wxString()) - + DisplayPath(cwd)); - } + // 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())); @@ -2661,6 +2673,7 @@ bool ChatFrame::ShowThread(const std::string& id) { if (!t.failedPrompt.empty() && input_->IsEmpty()) input_->SetValue(wxString::FromUTF8(t.failedPrompt)); t.failedPrompt.clear(); + t.unseen = false; RefreshSessionChoice(); if (otherFolder) { PopulateEditorTree(); @@ -4441,6 +4454,7 @@ void ChatFrame::StartTurn(Thread& t, const wxString& userText, std::vector images, const std::string& hiddenInstructions) { t.overflowRetried = false; + t.stopRequested = false; t.failure.clear(); t.turnModelIndex = ModelIndexFor(t); const ModelRoute route = RouteForIndex(t.turnModelIndex, remoteModels_); @@ -5333,6 +5347,7 @@ void ChatFrame::OnToolBatchDone(Thread& t) { } 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. diff --git a/src/chat_frame.h b/src/chat_frame.h index 213add9..f6d3128 100644 --- a/src/chat_frame.h +++ b/src/chat_frame.h @@ -144,6 +144,11 @@ class ChatFrame : public wxFrame { // 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; diff --git a/src/mcp_server.h b/src/mcp_server.h index 5dbe40d..9ab84fb 100644 --- a/src/mcp_server.h +++ b/src/mcp_server.h @@ -55,7 +55,7 @@ struct MCPCallbacks { std::function newSession; // Threads. listThreads returns {threads: [{id, cwd, title, lastUsed, - // loaded, running, failed}, ...], activeId}; `failed` marks a + // 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 diff --git a/src/thread_list.cpp b/src/thread_list.cpp index f978358..73c4b63 100644 --- a/src/thread_list.cpp +++ b/src/thread_list.cpp @@ -47,7 +47,7 @@ void ThreadList::OnDrawItem(wxDC& dc, const wxRect& rect, size_t n) const { 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) { + } 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); // "●" } diff --git a/src/thread_list.h b/src/thread_list.h index 3448df4..0d89af4 100644 --- a/src/thread_list.h +++ b/src/thread_list.h @@ -5,7 +5,8 @@ // One row of the thread pane. struct ThreadRow { - enum class State { Done, Running, Failed }; + // 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 From cd8d94258b00bcab67693e3697e551d0cfc3e4d1 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 25 Oct 2026 17:06:50 +0100 Subject: [PATCH 33/33] docs: README for threads, subagents and the project brief Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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