Skip to content

Commit 2c3bada

Browse files
author
Yogthos
committed
feat(ui): ov2 Phase A — multi-chat snapshot model in Renderer
First milestone of dirge-ov2 (subagent multi-chat-windows + Ctrl-N/P + /tasks picker, ported from maki). Pure additive change — single- chat behavior unchanged, backward-compatible for all current call sites. ## What `Renderer` gains: - `chats: Vec<ChatSnapshot>` — saved state of inactive chats - `active_chat: usize` — index of the chat currently on screen - `add_chat(name) -> usize` — append a new chat (typically a subagent), returns its index - `switch_chat(idx)` — saves current state to chats[active_chat], loads chats[idx] into the hot fields - `active_chat()` / `chat_count()` / `chat_names()` — read accessors `ChatSnapshot` mirrors the per-chat fields (buffer, partial, partial_color, scroll_offset, lines, col, selection_active/start/ end). The HOT path is unchanged — the active chat's state lives in the Renderer's own fields; snapshots only matter at chat-switch boundaries via `save_active` / `load_active`. ## Why this shape Alternative considered: make every per-chat field be a Vec<T> indexed by chat. Rejected — would touch every method in the renderer and every call site. The save/swap pattern keeps render hot-paths byte-for-byte identical to the single-chat baseline. `Renderer::new()` creates one default chat named "main" at index 0. Code that doesn't call `add_chat` / `switch_chat` sees zero behavior change. ## Test `chat_snapshot_save_load_roundtrip` exercises add/switch/restore: - main chat seeded with content + scroll position - subagent chat added and switched to (starts empty) - subagent populated independently - switch back to main → main's state restored - switch back to subagent → subagent's state restored - same-chat switch is no-op - out-of-range index is no-op (defensive) ## Next phases - Phase B: Ctrl-N/P/X keybinds + /tasks slash command (rebind interjection-drop to Alt+X) - Phase C: per-chat UI state in ui/mod.rs (response_buf etc.) - Phase D: refactor `task` tool to spawn full agent (with tools) - Phase E: subagent event router to chat buffer
1 parent 5469c4c commit 2c3bada

2 files changed

Lines changed: 190 additions & 1 deletion

File tree

.beads/issues.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
{"_type":"issue","id":"dirge-86e","title":"ANSI injection in permission ALERT prompt","description":"ask_req.tool / ask_req.input rendered un-sanitized at mod.rs:2584-2585. Reopen path already sanitizes — asymmetric. Sec impl: ANSI at the permission-decision moment.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-21T22:17:34Z","created_by":"Yogthos","updated_at":"2026-05-21T22:26:37Z","started_at":"2026-05-21T22:17:42Z","closed_at":"2026-05-21T22:26:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
2626
{"_type":"issue","id":"dirge-9f1","title":"Chat history ignores 120-col content_width cap","description":"max_line_width and wrap_line use raw content_cols, so on wide terminals scrollback overflows the centered band into divider/panel margin.","status":"closed","priority":1,"issue_type":"bug","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-21T22:17:33Z","created_by":"Yogthos","updated_at":"2026-05-21T22:26:36Z","started_at":"2026-05-21T22:17:42Z","closed_at":"2026-05-21T22:26:36Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
2727
{"_type":"issue","id":"dirge-woq","title":"R1: fix 3 critical plugin bugs (FFI panic, dialog deadlock, init hang)","description":"From the plugin subsystem audit: (1) wrap JanetCFunctions in catch_unwind so Rust panics don't unwind across the C-FFI boundary into Janet; (2) cancel send_dialog's reply_rx.recv() on worker shutdown so the worker thread doesn't block forever when the UI exits mid-dialog; (3) add timeout to the init handshake so a worker panic before init_tx.send() doesn't hang the main thread. Also: (4) bounds-assert wrap_string's i32 cast for the unlikely \u003e2GB case, (5) make take_string_slot atomic to close the race window, (6) don't eat unrelated user events in the dialog arm.","status":"closed","priority":1,"issue_type":"bug","owner":"yogthos@gmail.com","created_at":"2026-05-20T14:59:57Z","created_by":"Yogthos","updated_at":"2026-05-20T15:30:28Z","started_at":"2026-05-20T15:00:10Z","closed_at":"2026-05-20T15:30:28Z","dependency_count":0,"dependent_count":1,"comment_count":0}
28-
{"_type":"issue","id":"dirge-ov2","title":"UI: subagent multi-chat-window (Ctrl-N/P, /tasks picker) — port from maki","description":"Each subagent task gets its own chat window; navigate via Ctrl-N (next) / Ctrl-P (previous) and /tasks slash command opens a picker. Maki: maki-ui/src/app/mod.rs:317-322 (open_tasks), :341-352 (Ctrl-N/P), components/keybindings.rs:135-136 (NEXT_CHAT/PREV_CHAT), components/command.rs:22-23 (/tasks).\n\nBigger refactor: dirge's renderer holds ONE buffer. Need to model Vec\u003cChat\u003e with active_chat index, swap renderer buffer on switch. Conflict: dirge's Ctrl-X currently drops queued interjections — would need a different binding for next-task, or rebind interjection-drop. Coordinate with user before starting.","status":"open","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-23T17:37:07Z","created_by":"Yogthos","updated_at":"2026-05-23T17:37:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
28+
{"_type":"issue","id":"dirge-ov2","title":"UI: subagent multi-chat-window (Ctrl-N/P, /tasks picker) — port from maki","description":"Each subagent task gets its own chat window; navigate via Ctrl-N (next) / Ctrl-P (previous) and /tasks slash command opens a picker. Maki: maki-ui/src/app/mod.rs:317-322 (open_tasks), :341-352 (Ctrl-N/P), components/keybindings.rs:135-136 (NEXT_CHAT/PREV_CHAT), components/command.rs:22-23 (/tasks).\n\nBigger refactor: dirge's renderer holds ONE buffer. Need to model Vec\u003cChat\u003e with active_chat index, swap renderer buffer on switch. Conflict: dirge's Ctrl-X currently drops queued interjections — would need a different binding for next-task, or rebind interjection-drop. Coordinate with user before starting.","notes":"## Design (post-scope-decision: user picked Heavier refactor)\n\nFive phases, each independently committable so we can ship partial\nprogress if needed.\n\n### Phase A: Multi-buffer in Renderer (~200-300 lines)\n\nPer-chat state extracted into a `ChatBuffer` struct:\n- buffer: Vec\u003cLineEntry\u003e\n- partial: CompactString + partial_color\n- scroll_offset, lines, col\n- selection_active, selection_start, selection_end\n\nGlobal state stays on Renderer:\n- input_rows, monochrome\n- panel_mode, panel_data\n- avatar_state, avatar_tick\n\nRenderer gains:\n- chats: Vec\u003cChatBuffer\u003e\n- active_chat: usize\n- add_chat(name) -\u003e ChatId\n- switch_chat(id)\n- chat_names()\n\nAll existing public methods (write_line, write, replace_from,\nrender_viewport, scroll_*, selection_*) route to chats[active_chat].\n\nBackward compat: Renderer::new() creates one default chat (active=0).\nSingle-chat usage = existing behavior.\n\n### Phase B: Keybinds + /tasks (~80 lines)\n\n- Ctrl-N: next chat\n- Ctrl-P: prev chat\n- Ctrl-X: open /tasks picker (rebind interjection-drop to Alt+X\n per ov2 plan)\n- `/tasks` slash command: same picker\n\nPicker shows chat names; selection switches active.\n\n### Phase C: Per-chat UI state (~150 lines)\n\nui/mod.rs has 10+ state variables that are currently per-session:\n- response_buf, response_start_line\n- reasoning_buf, reasoning_start_line\n- last_tool_name, last_tool_call_id\n- tool_chamber_open, agent_line_started\n- was_reasoning, tool_calls_buf\n\nMove to a `ChatUiState` struct, one per chat. The UI loop reads\nstate from `chat_states[active_chat]` on each event.\n\n### Phase D: task tool refactor (~200 lines)\n\nCurrently `task` tool calls `model.btw_query(prompt)` — one-shot,\nno tools. Refactor to:\n- Spawn a sub-runner using same provider but limited tool set\n (read-only? or same as parent? — needs decision)\n- Subagent emits AgentEvents to its own mpsc channel\n- task tool returns the chat_id; UI routes events from the channel\n to chats[chat_id]\n\n### Phase E: Event router (~80 lines)\n\nUI loop selects across multiple agent_rx channels (one per active\nsubagent). Each event lands in chat_states[event.chat_id] and\nrenders via chats[event.chat_id].\n\n## Open question for Phase D\n\nShould subagent inherit parent's tool set, or be restricted to\nread-only? Maki gives subagents full tools by default. Dirge's\npermission system already gates per-tool, so safety isn't broken\neither way. Inheriting is simpler.","status":"in_progress","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-23T17:37:07Z","created_by":"Yogthos","updated_at":"2026-05-23T17:59:28Z","started_at":"2026-05-23T17:53:50Z","dependency_count":0,"dependent_count":0,"comment_count":0}
2929
{"_type":"issue","id":"dirge-rmk","title":"CLI: --output-format stream-json headless mode (port from maki)","description":"dirge has --print boolean but no output format selector. Port from maki:\n- maki/src/main.rs:32-58 (Cli with --print + --output-format)\n- maki/src/print.rs:44-49 (OutputFormat enum: Text | Json | StreamJson)\n- maki/src/print.rs:51-110 (PrintResult / InitEvent / AssistantEvent / UserEvent structs — Claude-Code-compatible JSON schema)\n- maki/src/print.rs:126-388 (headless run loop with NDJSON event serialization at lines 280, 285, 304, 325, 334)\n\nAdapt: dirge's run_print already exists at src/provider.rs:572. Extend it with format dispatch. For stream-json: serde-serialize each AgentEvent (or a JSON-shaped envelope) and println! one object per line.","status":"closed","priority":2,"issue_type":"feature","owner":"yogthos@gmail.com","created_at":"2026-05-23T17:37:06Z","created_by":"Yogthos","updated_at":"2026-05-23T17:51:48Z","started_at":"2026-05-23T17:44:18Z","closed_at":"2026-05-23T17:51:48Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
3030
{"_type":"issue","id":"dirge-efw","title":"Perm review F3: space-star pattern optional-arg semantics","description":"LOW-polish gap found in opencode-vs-dirge review. Opencode's wildcard matcher (util/wildcard.ts:13-15) rewrites trailing ' *' as '( .*)?' — making the trailing args optional. So 'ls *' matches BOTH 'ls' and 'ls -la'. Dirge's pattern.rs glob_to_regex requires the args (' *' becomes ' .*' which needs at least the space).\n\nConcrete UX friction: a user accepts 'ls *' from a session allowlist after the agent runs 'ls -la', then the agent runs bare 'ls' — re-prompted because the saved pattern doesn't match. Port the optional-suffix rewrite to src/permission/pattern.rs's glob_to_regex.","status":"closed","priority":2,"issue_type":"task","owner":"yogthos@gmail.com","created_at":"2026-05-23T15:48:20Z","created_by":"Yogthos","updated_at":"2026-05-23T16:07:24Z","started_at":"2026-05-23T15:57:07Z","closed_at":"2026-05-23T16:07:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
3131
{"_type":"issue","id":"dirge-84j","title":"B3-9: edit tool fuzzy-match cascade","description":"edit.rs:155-160 returns 'old_text not found' on whitespace/indent/CRLF drift. LLMs frequently hit this. opencode edit.ts:222-432 has simple → lineTrimmed → whitespace-normalized → indentation-flexible → levenshtein. pi edit-diff.ts:91-132 has fuzzyFindText. Port the cascade.","status":"closed","priority":2,"issue_type":"feature","assignee":"Yogthos","owner":"yogthos@gmail.com","created_at":"2026-05-23T03:24:44Z","created_by":"Yogthos","updated_at":"2026-05-23T03:43:39Z","started_at":"2026-05-23T03:37:16Z","closed_at":"2026-05-23T03:43:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

src/ui/renderer.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,29 @@ pub struct PanelData {
5858
pub modified: Vec<String>,
5959
}
6060

61+
/// Per-chat state saved while a chat is INACTIVE. Mirrors the fields
62+
/// the active chat uses on the `Renderer` itself; switching chats
63+
/// swaps state in/out via `save_active` / `load_active`. Keeps the
64+
/// hot-path rendering code unchanged — only chat-switch boundaries
65+
/// pay the snapshot cost.
66+
///
67+
/// dirge-ov2 Phase A: enables multiple subagent chat windows. The
68+
/// main session is always at index 0; subagent chats start at index
69+
/// 1. Selection state lives per-chat because a selection in chat A
70+
/// would be meaningless when chat B is on screen.
71+
pub struct ChatSnapshot {
72+
pub name: String,
73+
buffer: Vec<LineEntry>,
74+
partial: CompactString,
75+
partial_color: Color,
76+
scroll_offset: usize,
77+
lines: u16,
78+
col: u16,
79+
selection_active: bool,
80+
selection_start: Option<(usize, usize)>,
81+
selection_end: Option<(usize, usize)>,
82+
}
83+
6184
pub struct Renderer {
6285
lines: u16,
6386
col: u16,
@@ -66,6 +89,13 @@ pub struct Renderer {
6689
partial: CompactString,
6790
partial_color: Color,
6891
scroll_offset: usize,
92+
/// dirge-ov2: snapshots of the OTHER chats — the active chat's
93+
/// state lives in the fields above. `chats[active_chat]` is the
94+
/// "free slot" (its name/buffer match what's on screen but the
95+
/// fields haven't been written into it yet; switching chats
96+
/// flushes them).
97+
chats: Vec<ChatSnapshot>,
98+
active_chat: usize,
6999
/// Number of rows the input area currently occupies (1 by default, grows
70100
/// up to MAX_INPUT_VISIBLE_LINES as the user adds newlines or types past
71101
/// the wrap width). The chat viewport shrinks by the same amount.
@@ -100,6 +130,11 @@ impl Renderer {
100130
partial: CompactString::new(""),
101131
partial_color: Color::White,
102132
scroll_offset: 0,
133+
// dirge-ov2: one default "main" chat. Subagent chats are
134+
// appended via `add_chat`. Index 0 is always the main
135+
// session.
136+
chats: vec![ChatSnapshot::empty("main")],
137+
active_chat: 0,
103138
input_rows: 1,
104139
monochrome: false,
105140
selection_active: false,
@@ -112,6 +147,103 @@ impl Renderer {
112147
})
113148
}
114149

150+
/// dirge-ov2: append a new chat (typically a subagent) with the
151+
/// supplied display name. Returns the new chat's index, which the
152+
/// caller stores so it can target events at this chat later via
153+
/// `switch_chat`.
154+
///
155+
/// The new chat starts empty — no buffer entries, no selection,
156+
/// no scroll. Does NOT switch to it; the caller chooses when to
157+
/// surface the new chat in the UI.
158+
pub fn add_chat(&mut self, name: impl Into<String>) -> usize {
159+
self.chats.push(ChatSnapshot::empty(name.into()));
160+
self.chats.len() - 1
161+
}
162+
163+
/// dirge-ov2: switch the active chat. Saves the current chat's
164+
/// state to its snapshot, loads the target chat's snapshot into
165+
/// the Renderer's hot fields, and triggers a viewport repaint via
166+
/// the next render call. No-op if `idx == active_chat`.
167+
pub fn switch_chat(&mut self, idx: usize) {
168+
if idx == self.active_chat || idx >= self.chats.len() {
169+
return;
170+
}
171+
self.save_active();
172+
self.active_chat = idx;
173+
self.load_active();
174+
}
175+
176+
pub fn active_chat(&self) -> usize {
177+
self.active_chat
178+
}
179+
180+
pub fn chat_count(&self) -> usize {
181+
self.chats.len()
182+
}
183+
184+
pub fn chat_names(&self) -> Vec<String> {
185+
// Active chat's name lives in `chats[active_chat]` too (kept
186+
// in sync at add-time; mutations of the active chat's name
187+
// would go through a dedicated setter if added later).
188+
self.chats.iter().map(|c| c.name.clone()).collect()
189+
}
190+
191+
/// dirge-ov2: snapshot the current hot fields into the active
192+
/// chat's slot. Called before switching chats and when the
193+
/// caller wants a consistent persistent state (e.g. session
194+
/// save).
195+
fn save_active(&mut self) {
196+
let slot = &mut self.chats[self.active_chat];
197+
slot.buffer = std::mem::take(&mut self.buffer);
198+
slot.partial = std::mem::take(&mut self.partial);
199+
slot.partial_color = self.partial_color;
200+
slot.scroll_offset = self.scroll_offset;
201+
slot.lines = self.lines;
202+
slot.col = self.col;
203+
slot.selection_active = self.selection_active;
204+
slot.selection_start = self.selection_start;
205+
slot.selection_end = self.selection_end;
206+
}
207+
208+
/// dirge-ov2: load the active chat's snapshot into the hot
209+
/// fields. Inverse of `save_active`. Called after `switch_chat`
210+
/// updates `active_chat`.
211+
fn load_active(&mut self) {
212+
let slot = &mut self.chats[self.active_chat];
213+
self.buffer = std::mem::take(&mut slot.buffer);
214+
self.partial = std::mem::take(&mut slot.partial);
215+
self.partial_color = slot.partial_color;
216+
self.scroll_offset = slot.scroll_offset;
217+
self.lines = slot.lines;
218+
self.col = slot.col;
219+
self.selection_active = slot.selection_active;
220+
self.selection_start = slot.selection_start;
221+
self.selection_end = slot.selection_end;
222+
}
223+
}
224+
225+
impl ChatSnapshot {
226+
fn empty(name: impl Into<String>) -> Self {
227+
Self {
228+
name: name.into(),
229+
buffer: Vec::new(),
230+
partial: CompactString::new(""),
231+
partial_color: Color::White,
232+
scroll_offset: 0,
233+
lines: 0,
234+
col: 0,
235+
selection_active: false,
236+
selection_start: None,
237+
selection_end: None,
238+
}
239+
}
240+
}
241+
242+
#[allow(dead_code)]
243+
impl Renderer {
244+
// (continuation marker — methods below this block remain unchanged)
245+
fn _ov2_phase_a_anchor() {}
246+
115247
/// Update the avatar state and trigger a repaint of the bottom-left
116248
/// pixels. Cheap when the state hasn't changed — only the existing
117249
/// 3-row × 5-col patch is re-drawn.
@@ -1635,6 +1767,63 @@ pub fn copy_to_clipboard(text: &str) {
16351767
mod tests {
16361768
use super::*;
16371769

1770+
/// dirge-ov2 Phase A: chat switching saves the prior chat's
1771+
/// buffer and selection, then loads the target chat's snapshot.
1772+
/// Round-trip preserves content.
1773+
#[test]
1774+
fn chat_snapshot_save_load_roundtrip() {
1775+
let mut r = Renderer::new().expect("renderer");
1776+
// Default chat is "main" at index 0.
1777+
assert_eq!(r.active_chat(), 0);
1778+
assert_eq!(r.chat_count(), 1);
1779+
assert_eq!(r.chat_names(), vec!["main".to_string()]);
1780+
1781+
// Seed main chat with some content.
1782+
r.buffer.push(LineEntry {
1783+
text: CompactString::new("main-line-1"),
1784+
color: Color::White,
1785+
});
1786+
r.scroll_offset = 5;
1787+
1788+
// Spawn a subagent chat and switch to it.
1789+
let sub_idx = r.add_chat("subagent-1");
1790+
assert_eq!(sub_idx, 1);
1791+
assert_eq!(r.chat_count(), 2);
1792+
r.switch_chat(sub_idx);
1793+
assert_eq!(r.active_chat(), 1);
1794+
1795+
// Subagent chat starts empty.
1796+
assert!(r.buffer.is_empty());
1797+
assert_eq!(r.scroll_offset, 0);
1798+
1799+
// Add content to the subagent chat.
1800+
r.buffer.push(LineEntry {
1801+
text: CompactString::new("sub-line-1"),
1802+
color: Color::Cyan,
1803+
});
1804+
r.scroll_offset = 2;
1805+
1806+
// Switch back to main — its content must be restored.
1807+
r.switch_chat(0);
1808+
assert_eq!(r.buffer.len(), 1);
1809+
assert_eq!(r.buffer[0].text.as_str(), "main-line-1");
1810+
assert_eq!(r.scroll_offset, 5);
1811+
1812+
// Switch back to subagent — its content also restored.
1813+
r.switch_chat(1);
1814+
assert_eq!(r.buffer.len(), 1);
1815+
assert_eq!(r.buffer[0].text.as_str(), "sub-line-1");
1816+
assert_eq!(r.scroll_offset, 2);
1817+
1818+
// Switch to same chat is a no-op.
1819+
r.switch_chat(1);
1820+
assert_eq!(r.buffer.len(), 1);
1821+
1822+
// Out-of-range index is a no-op (defensive — caller bug).
1823+
r.switch_chat(99);
1824+
assert_eq!(r.active_chat(), 1);
1825+
}
1826+
16381827
/// Create a renderer with a synthetic buffer of `n` short lines so we
16391828
/// can drive scroll/append behavior without touching a real terminal.
16401829
fn fresh_with_lines(n: usize) -> Renderer {

0 commit comments

Comments
 (0)