Skip to content

Commit 39d375c

Browse files
committed
feat(tui): toggle ACP recording from agent picker
1 parent b765e54 commit 39d375c

14 files changed

Lines changed: 418 additions & 28 deletions

nori-rs/acp/docs.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ When enabled, the resolved `AcpProxyConfig` stores logs under `$NORI_HOME/acp-wi
142142

143143
The connection layer uses `sacp::Lines` to observe raw newline-delimited JSON-RPC messages at the transport boundary before or after SACP parsing. Each child process gets a distinct JSONL file named from the launch timestamp, child PID, and sanitized agent slug. Records include the timestamp, direction (`client_to_agent` or `agent_to_client`), agent slug, child PID, and the parsed JSON message. If a line cannot be parsed as JSON, the logger preserves the raw line and parse error instead of disrupting the live session.
144144

145+
The TUI's `/agent` picker can persistently toggle this setting with `Shift-Tab`. Because the proxy wraps subprocess transports at spawn time, the toggle is intentionally a future-process setting: newly spawned ACP child subprocesses observe the updated config, while already-running subprocesses continue with the proxy state they started with.
146+
145147
**Agent Config Field Resolution:**
146148

147149
| Field | Purpose | Persistence |

nori-rs/tui/docs.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Key dependencies: `ratatui` for rendering, `crossterm` for terminal events, `pul
3131

3232
Entry point is `main.rs` which delegates to `run_app()` in `lib.rs`. The `run_main()` function loads `NoriConfig` once early and reuses it for both the auto-worktree setup and the `vertical_footer` setting (passed as a parameter to `run_ratatui_app()`). After loading config, `run_main()` initializes the agent registry via `nori_acp::initialize_registry()` with any custom `[[agents]]` defined in `config.toml` (see `@/nori-rs/acp/docs.md` for registry details). Initialization failure is non-fatal (logged as a warning).
3333

34-
`NoriConfig` is also the source of truth for ACP backend diagnostics that do not have direct TUI controls yet. The chat widget passes the resolved ACP proxy configuration into `AcpBackendConfig` when spawning or resuming sessions, so enabling `[acp_proxy]` in config wraps every backend ACP subprocess in the wire logger without needing UI state in the TUI layer.
34+
`NoriConfig` is also the source of truth for ACP backend diagnostics. The chat widget passes the resolved ACP proxy configuration into `AcpBackendConfig` when spawning or resuming sessions, so enabling `[acp_proxy]` in config wraps every backend ACP subprocess in the wire logger without requiring the live backend to be reconfigured in place.
3535

3636
The auto-worktree startup flow branches on the `AutoWorktree` enum (see `@/nori-rs/acp/docs.md`):
3737

@@ -195,7 +195,7 @@ The drawer is inserted into the `FlexRenderable` layout in `ChatWidget::as_rende
195195

196196
The config persists a boolean `pinned_plan_drawer` in `[tui]` of `config.toml`. At startup, `true` maps to `Expanded` and `false` maps to `Off`. Runtime toggling via Ctrl+O does not persist -- only the `/config` toggle persists.
197197

198-
The Nori-specific agent picker UI lives in `nori/agent_picker.rs`, allowing users to select between available ACP agents.
198+
The Nori-specific agent picker UI lives in `nori/agent_picker.rs`, allowing users to select between available ACP agents. It also exposes the ACP wire JSONL recorder as a same-line footer hint: `Shift-Tab` toggles `[acp_proxy].enabled` through the app config persistence path, updates the open picker and slash-command status text, and applies to future ACP child subprocesses. Existing running ACP subprocesses keep the proxy setting they were spawned with.
199199

200200
**System Info Collection** (`system_info.rs`):
201201

nori-rs/tui/src/app/config_persistence.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
use super::*;
2+
use std::path::Path;
3+
4+
async fn persist_acp_wire_recording_config(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
5+
ConfigEditsBuilder::new(codex_home)
6+
.set_path(&["acp_proxy", "enabled"], toml_value(enabled))
7+
.apply()
8+
.await
9+
}
210

311
impl App {
412
/// Persist a TUI config setting to config.toml and apply it immediately.
@@ -191,6 +199,23 @@ impl App {
191199
.add_info_message(format!("Pinned plan drawer {status}."), None);
192200
}
193201

202+
#[cfg(feature = "nori-config")]
203+
pub(super) async fn persist_acp_wire_recording_setting(&mut self, enabled: bool) {
204+
if let Err(err) = persist_acp_wire_recording_config(&self.config.codex_home, enabled).await
205+
{
206+
tracing::error!(error = %err, "failed to persist acp wire recording setting");
207+
self.chat_widget
208+
.add_error_message(format!("Failed to save ACP wire recording setting: {err}"));
209+
return;
210+
}
211+
212+
self.chat_widget.set_acp_wire_recording_enabled(enabled);
213+
self.chat_widget.replace_agent_popup(enabled);
214+
let status = if enabled { "enabled" } else { "disabled" };
215+
self.chat_widget
216+
.add_info_message(format!("ACP wire recording {status}."), None);
217+
}
218+
194219
#[cfg(feature = "nori-config")]
195220
pub(super) async fn persist_skillset_per_session_setting(&mut self, enabled: bool) {
196221
let builder = ConfigEditsBuilder::new(&self.config.codex_home)
@@ -435,3 +460,29 @@ impl App {
435460
}
436461
}
437462
}
463+
464+
#[cfg(test)]
465+
mod tests {
466+
use super::*;
467+
use tempfile::TempDir;
468+
469+
#[tokio::test]
470+
async fn persists_acp_wire_recording_to_top_level_acp_proxy_section() {
471+
let temp = TempDir::new().expect("temp home");
472+
473+
persist_acp_wire_recording_config(temp.path(), true)
474+
.await
475+
.expect("persist recording enabled");
476+
477+
let content = std::fs::read_to_string(temp.path().join("config.toml"))
478+
.expect("read persisted config");
479+
let parsed: toml::Value = toml::from_str(&content).expect("config toml");
480+
assert_eq!(
481+
parsed
482+
.get("acp_proxy")
483+
.and_then(|section| section.get("enabled"))
484+
.and_then(toml::Value::as_bool),
485+
Some(true)
486+
);
487+
}
488+
}

nori-rs/tui/src/app/event_handling.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,10 @@ impl App {
797797
self.persist_pinned_plan_drawer_setting(enabled).await;
798798
}
799799
#[cfg(feature = "nori-config")]
800+
AppEvent::SetConfigAcpWireRecording(enabled) => {
801+
self.persist_acp_wire_recording_setting(enabled).await;
802+
}
803+
#[cfg(feature = "nori-config")]
800804
AppEvent::OpenSkillsetPerSessionWorktreeChoice => {
801805
self.chat_widget.open_skillset_worktree_choice_picker();
802806
}

nori-rs/tui/src/app_event.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,10 @@ pub(crate) enum AppEvent {
320320
#[cfg(feature = "nori-config")]
321321
SetConfigPinnedPlanDrawer(bool),
322322

323+
/// Set ACP wire JSONL recording for future ACP child subprocesses.
324+
#[cfg(feature = "nori-config")]
325+
SetConfigAcpWireRecording(bool),
326+
323327
/// Open the worktree choice modal when enabling per-session skillsets.
324328
#[cfg(feature = "nori-config")]
325329
OpenSkillsetPerSessionWorktreeChoice,

nori-rs/tui/src/bottom_pane/chat_composer/mod.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ pub(crate) struct ChatComposer {
116116
custom_prompts: Vec<CustomPrompt>,
117117
agent_commands: Vec<nori_protocol::AgentCommandInfo>,
118118
agent_command_prefix: String,
119-
command_description_overrides: HashMap<SlashCommand, String>,
119+
command_description_overrides: HashMap<SlashCommand, Line<'static>>,
120120
footer_mode: FooterMode,
121121
footer_hint_override: Option<Vec<(String, String)>>,
122122
context_window_percent: Option<i64>,
@@ -391,6 +391,15 @@ impl ChatComposer {
391391
}
392392

393393
pub(crate) fn set_command_description_override(&mut self, cmd: SlashCommand, desc: String) {
394+
self.command_description_overrides
395+
.insert(cmd, Line::from(desc));
396+
}
397+
398+
pub(crate) fn set_command_description_override_line(
399+
&mut self,
400+
cmd: SlashCommand,
401+
desc: Line<'static>,
402+
) {
394403
self.command_description_overrides.insert(cmd, desc);
395404
}
396405

nori-rs/tui/src/bottom_pane/command_popup.rs

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use ratatui::buffer::Buffer;
22
use ratatui::layout::Rect;
3+
use ratatui::text::Line;
34
use ratatui::widgets::WidgetRef;
45

56
use super::popup_consts::MAX_POPUP_ROWS;
@@ -35,7 +36,7 @@ pub(crate) struct CommandPopup {
3536
agent_commands: Vec<AgentCommandInfo>,
3637
agent_command_prefix: String,
3738
state: ScrollState,
38-
description_overrides: HashMap<SlashCommand, String>,
39+
description_overrides: HashMap<SlashCommand, Line<'static>>,
3940
}
4041

4142
impl CommandPopup {
@@ -47,7 +48,7 @@ impl CommandPopup {
4748
#[cfg(test)]
4849
pub(crate) fn new_with_overrides(
4950
prompts: Vec<CustomPrompt>,
50-
description_overrides: HashMap<SlashCommand, String>,
51+
description_overrides: HashMap<SlashCommand, Line<'static>>,
5152
) -> Self {
5253
Self::new_full(prompts, Vec::new(), String::new(), description_overrides)
5354
}
@@ -56,7 +57,7 @@ impl CommandPopup {
5657
mut prompts: Vec<CustomPrompt>,
5758
agent_commands: Vec<AgentCommandInfo>,
5859
agent_command_prefix: String,
59-
description_overrides: HashMap<SlashCommand, String>,
60+
description_overrides: HashMap<SlashCommand, Line<'static>>,
6061
) -> Self {
6162
let builtins = built_in_slash_commands();
6263
// Exclude prompts that collide with builtin command names and sort by name.
@@ -229,14 +230,20 @@ impl CommandPopup {
229230
matches
230231
.into_iter()
231232
.map(|(item, indices, _)| {
232-
let (name, description) = match item {
233+
let (name, description, styled_description) = match item {
233234
CommandItem::Builtin(cmd) => {
234-
let desc = self
235-
.description_overrides
236-
.get(&cmd)
237-
.cloned()
238-
.unwrap_or_else(|| cmd.description().to_string());
239-
(format!("/{}", cmd.command()), desc)
235+
let (description, styled_description) = if let Some(desc_line) =
236+
self.description_overrides.get(&cmd).cloned()
237+
{
238+
(desc_line.to_string(), Some(desc_line))
239+
} else {
240+
(cmd.description().to_string(), None)
241+
};
242+
(
243+
format!("/{}", cmd.command()),
244+
description,
245+
styled_description,
246+
)
240247
}
241248
CommandItem::UserPrompt(i) => {
242249
let prompt = &self.prompts[i];
@@ -247,19 +254,21 @@ impl CommandPopup {
247254
(
248255
format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name),
249256
description,
257+
None,
250258
)
251259
}
252260
CommandItem::AgentCommand(i) => {
253261
let display_key = self.agent_command_display_key(i);
254262
let cmd = &self.agent_commands[i];
255-
(format!("/{display_key}"), cmd.description.clone())
263+
(format!("/{display_key}"), cmd.description.clone(), None)
256264
}
257265
};
258266
GenericDisplayRow {
259267
name,
260268
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
261269
display_shortcut: None,
262270
description: Some(description),
271+
styled_description,
263272
}
264273
})
265274
.collect()
@@ -452,7 +461,7 @@ mod tests {
452461
let mut overrides = HashMap::new();
453462
overrides.insert(
454463
SlashCommand::Agent,
455-
"switch between available ACP agents (current: Claude Code)".to_string(),
464+
Line::from("switch between available ACP agents (current: Claude Code)"),
456465
);
457466
let popup = CommandPopup::new_with_overrides(Vec::new(), overrides);
458467
let rows =
@@ -469,7 +478,7 @@ mod tests {
469478
let mut overrides = HashMap::new();
470479
overrides.insert(
471480
SlashCommand::Agent,
472-
"switch between available ACP agents (current: Claude Code)".to_string(),
481+
Line::from("switch between available ACP agents (current: Claude Code)"),
473482
);
474483
let popup = CommandPopup::new_with_overrides(Vec::new(), overrides);
475484
let rows =

nori-rs/tui/src/bottom_pane/file_search_popup.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ impl WidgetRef for &FileSearchPopup {
131131
.map(|v| v.iter().map(|&i| i as usize).collect()),
132132
display_shortcut: None,
133133
description: None,
134+
styled_description: None,
134135
})
135136
.collect()
136137
};

0 commit comments

Comments
 (0)