Skip to content

Commit 3a0afe3

Browse files
committed
Group MCP servers under one banner header, route them above the TUI viewport, and quieten their stderr relay
1 parent a565ba9 commit 3a0afe3

7 files changed

Lines changed: 111 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ All notable changes to Sofos are documented in this file.
1919
- **A pathologically large token count from the Anthropic API now saturates at the 32-bit ceiling instead of wrapping to a small number.** The 32-bit ceiling sits well above any realistic single-turn count, so this only matters as a defence against a misreported wire value, but if such a value ever arrives the reported count stays believable.
2020
- **The `anthropic-beta` header now agrees with the request body about which models support server-side compaction.** Earlier, the header check used its own model-prefix list while the body's `context_management` field used the per-model `ModelInfo` flag. A model that one source listed and the other did not (for example `claude-opus-4-5`) used to ship the compaction beta token while the body never carried the matching field, which can 400 on stricter validation. Both sources now read from the same `ModelInfo::supports_server_compaction` flag.
2121
- **Ctrl+Enter now inserts a newline in the TUI input box, matching Shift+Enter and Alt+Enter.** Earlier the keystroke was silently swallowed by the textarea router, even though the placeholder text and the dispatch comments already documented it as a fallback newline binding for terminals that do not deliver Shift+Enter distinctly.
22+
- **MCP server "initialized" lines no longer get scrolled off-screen by the TUI viewport at startup.** They used to print straight to stdout while sofos was still connecting servers, before the inline viewport anchored — on tight windows the viewport then scrolled the lines out of view as it made room for itself. The lines now ride the same deferred-banner replay path as the workspace/model header, so they always land above the viewport.
23+
- **MCP server stderr no longer floods the default log with WARN-level chatter, and the lines read as plain text instead of escaped ANSI.** Many MCP servers reserve stdout for JSON-RPC and emit their own INFO/DEBUG lines (often pre-coloured) to stderr; sofos used to relay every line through `tracing::warn!`, so a clean startup printed a wall of WARN messages with `\x1b[…]` escape sequences baked in. The relay now uses `tracing::debug!` and strips CSI runs first — real connect or list-tools failures still surface as WARN from `manager.rs`, and the raw lines are still available via `RUST_LOG=debug`.
2224
- **Setting `compaction_preserve_recent` to `0` no longer crashes the next compaction.** The split-point lookup used to index one past the end of the message list and panic. It now clamps to the last valid index, so a zero-preserve configuration just compacts everything older than the very last message.
2325
- **The summary call's token usage is now counted toward the session total.** When auto-compaction got a usable response from the model but the summary was too short to apply (fell back to plain trimming), sofos used to discard the response without billing it; the spend now lands on the session counters in every Ok path.
2426
- **The auto-compaction summary call no longer fights the prompt cache.** The one-shot summary used to share the OpenAI prompt-cache shard with the regular session, so the two distinct prefixes evicted each other on every compaction. The summary call now uses a `"<session>-summary"` shard of its own.

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ pulldown-cmark = "0.13"
2929
similar = "3"
3030
crossterm = "0.29"
3131
ratatui = { version = "0.30", features = ["unstable-rendered-line-info", "scrolling-regions"] }
32-
tui-textarea = { package = "tui-textarea-2", version = "0.10" }
32+
tui-textarea = { package = "tui-textarea-2", version = "0.11" }
3333
ansi-to-tui = "8"
3434
os_pipe = "1"
3535
libc = "0.2"

src/main.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,6 @@ fn main() -> Result<()> {
126126
}
127127
});
128128

129-
startup_banner.push('\n');
130-
131129
if !interactive_mode {
132130
print!("{}", startup_banner);
133131
}
@@ -140,8 +138,17 @@ fn main() -> Result<()> {
140138
);
141139

142140
let mut repl = Repl::new(client, config, workspace.clone(), morph_client)?;
141+
// MCP block sits flush below the workspace/model labels (no blank
142+
// line in between), then a single trailing newline separates the
143+
// banner from the welcome (interactive) or the next CLI output
144+
// (one-shot). When there are no servers the trailing `\n` alone
145+
// gives the same blank-line separator the old banner had.
146+
let mcp_section = format!("{}\n", repl.take_mcp_init_lines());
143147
if interactive_mode {
148+
startup_banner.push_str(&mcp_section);
144149
repl.set_startup_banner(startup_banner);
150+
} else {
151+
print!("{}", mcp_section);
145152
}
146153

147154
if cli.resume {

src/mcp/manager.rs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,21 @@ pub struct McpManager {
4444
}
4545

4646
impl McpManager {
47-
pub async fn new(workspace: PathBuf) -> Result<Self> {
47+
/// Returns the manager and a pre-formatted startup block for the
48+
/// caller to fold into the TUI banner. The block is empty when no
49+
/// servers connected; otherwise it carries an `MCP servers:` header
50+
/// followed by one indented `✓ name (N tools)` bullet per server,
51+
/// matching the workspace/model section above it. Printing this
52+
/// block here would land on the raw terminal before `OutputCapture`
53+
/// is installed, and the inline viewport scrolls it off-screen when
54+
/// it anchors.
55+
pub async fn new(workspace: PathBuf) -> Result<(Self, String)> {
4856
let server_configs = load_mcp_config(&workspace);
4957

5058
let mut clients: HashMap<String, Arc<McpClient>> = HashMap::new();
5159
let mut tools_by_server: HashMap<String, Vec<McpTool>> = HashMap::new();
5260
let mut tool_to_server: HashMap<String, String> = HashMap::new();
61+
let mut bullets = String::new();
5362

5463
for (server_name, config) in server_configs {
5564
match McpClient::connect(server_name.clone(), config).await {
@@ -65,12 +74,12 @@ impl McpManager {
6574
}
6675
tools_by_server.insert(server_name.clone(), tools);
6776
clients.insert(server_name.clone(), Arc::new(client));
68-
println!(
69-
"{} MCP server '{}' initialized ({} tools)",
77+
bullets.push_str(&format!(
78+
" {} {} ({} tools)\n",
7079
"✓".bright_green(),
7180
server_name.bright_cyan(),
7281
tool_count
73-
);
82+
));
7483
}
7584
Err(e) => {
7685
tracing::warn!(
@@ -91,11 +100,17 @@ impl McpManager {
91100
}
92101
}
93102

94-
Ok(Self {
103+
let manager = Self {
95104
clients: Arc::new(Mutex::new(clients)),
96105
tools_by_server: Arc::new(tools_by_server),
97106
tool_to_server: Arc::new(tool_to_server),
98-
})
107+
};
108+
let init_block = if bullets.is_empty() {
109+
String::new()
110+
} else {
111+
format!("{}\n{}", "MCP servers:".bright_green(), bullets)
112+
};
113+
Ok((manager, init_block))
99114
}
100115

101116
/// Get all available MCP tools from all connected servers.

src/mcp/transport/stdio.rs

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,24 @@ fn take_child_pipes(
3838
}
3939

4040
/// Drain the child's stderr line-by-line on a blocking worker and route
41-
/// each line through `tracing::warn!` tagged with the server name. The
42-
/// previous transport used `Stdio::null()` for stderr, so a misconfigured
43-
/// or crashing MCP server gave the user no clue what was wrong — the
44-
/// only signal was an opaque "failed to parse" or "connection closed"
45-
/// downstream. Capturing through `tracing` keeps the noise out of the
46-
/// foreground TUI but makes the diagnostics discoverable via
47-
/// `SOFOS_LOG=warn` or wherever the user has tracing pointed.
41+
/// each line through `tracing::debug!` tagged with the server name.
42+
/// Servers reserve stdout for JSON-RPC and emit their own INFO/DEBUG
43+
/// logs to stderr, so treating every line as a warning floods the
44+
/// default-level (WARN) log with normal startup chatter. Real failures
45+
/// still surface as WARN from the connect / list-tools paths in
46+
/// `manager.rs`; raw stderr is opt-in via `RUST_LOG=debug`.
47+
///
48+
/// ANSI escapes are stripped before logging because tracing's default
49+
/// formatter renders control bytes as `\x1b[…]` literals, which is what
50+
/// the user actually sees on the terminal otherwise.
4851
fn spawn_stderr_reader(server_name: String, stderr: ChildStderr) {
4952
tokio::task::spawn_blocking(move || {
5053
let reader = BufReader::new(stderr);
5154
for line in reader.lines() {
5255
match line {
5356
Ok(text) => {
54-
tracing::warn!(server = %server_name, "mcp stderr: {}", text);
57+
let clean = strip_ansi_escapes(&text);
58+
tracing::debug!(server = %server_name, "mcp stderr: {}", clean);
5559
}
5660
Err(e) => {
5761
tracing::warn!(
@@ -66,6 +70,29 @@ fn spawn_stderr_reader(server_name: String, stderr: ChildStderr) {
6670
});
6771
}
6872

73+
/// Remove CSI sequences (`ESC [ … final-byte`) and the bare `ESC` so log
74+
/// lines stay readable when the child wraps its output in ANSI styling.
75+
/// Final bytes of a CSI run sit in `0x40..=0x7e`; we also tolerate a
76+
/// stray `ESC` with no following bracket by skipping the next char.
77+
fn strip_ansi_escapes(s: &str) -> String {
78+
let mut out = String::with_capacity(s.len());
79+
let mut chars = s.chars();
80+
while let Some(c) = chars.next() {
81+
if c != '\x1b' {
82+
out.push(c);
83+
continue;
84+
}
85+
if let Some('[') = chars.next() {
86+
for cc in chars.by_ref() {
87+
if matches!(cc, '\x40'..='\x7e') {
88+
break;
89+
}
90+
}
91+
}
92+
}
93+
out
94+
}
95+
6996
/// Write a single stdio message to the server (JSON-RPC request *or*
7097
/// notification). Shared by `send_request` and `send_notification` so
7198
/// the lock/write/flush sequence lives in one place.
@@ -353,3 +380,27 @@ impl StdioClient {
353380
parse_call_tool_response(result)
354381
}
355382
}
383+
384+
#[cfg(test)]
385+
mod tests {
386+
use super::strip_ansi_escapes;
387+
388+
#[test]
389+
fn strips_csi_color_run() {
390+
let input = "\x1b[2m2026-05-15T22:34:54.965614Z\x1b[0m \x1b[32m INFO\x1b[0m start";
391+
assert_eq!(
392+
strip_ansi_escapes(input),
393+
"2026-05-15T22:34:54.965614Z INFO start"
394+
);
395+
}
396+
397+
#[test]
398+
fn passes_plain_text_through() {
399+
assert_eq!(strip_ansi_escapes("no escapes here"), "no escapes here");
400+
}
401+
402+
#[test]
403+
fn drops_bare_escape() {
404+
assert_eq!(strip_ansi_escapes("a\x1bXb"), "ab");
405+
}
406+
}

src/repl/mod.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ pub struct Repl {
8080
/// (e.g. Ghostty), where the fallback origin would let the viewport
8181
/// overwrite the lines.
8282
pub(super) startup_banner: String,
83+
/// Per-server "✓ MCP server 'X' initialized (N tools)" lines built
84+
/// during [`Self::new`]. Held separately from `startup_banner` so
85+
/// `main.rs` can splice them in after its own workspace/model
86+
/// header before handing the combined banner back for the TUI to
87+
/// replay through its capture pipe.
88+
pub(super) mcp_init_lines: String,
8389
/// Shared tokio runtime driving every `block_on` in the REPL
8490
/// (initial request, compaction summary, tool-list refresh). Built
8591
/// once in [`Self::new`] and reused for the lifetime of the `Repl`.
@@ -105,12 +111,12 @@ impl Repl {
105111
let runtime = tokio::runtime::Runtime::new()
106112
.map_err(|e| SofosError::Config(format!("Failed to create async runtime: {}", e)))?;
107113

108-
let mcp_manager = runtime.block_on(async {
114+
let (mcp_manager, mcp_init_lines) = runtime.block_on(async {
109115
match McpManager::new(workspace.clone()).await {
110-
Ok(manager) => Some(manager),
116+
Ok((manager, block)) => (Some(manager), block),
111117
Err(e) => {
112118
tracing::warn!(error = %e, "failed to initialize MCP manager");
113-
None
119+
(None, String::new())
114120
}
115121
}
116122
});
@@ -192,6 +198,7 @@ impl Repl {
192198
interrupt_flag: Arc::new(AtomicBool::new(false)),
193199
steer_buffer: Arc::new(Mutex::new(Vec::new())),
194200
startup_banner: String::new(),
201+
mcp_init_lines,
195202
runtime,
196203
})
197204
}
@@ -212,6 +219,13 @@ impl Repl {
212219
std::mem::take(&mut self.startup_banner)
213220
}
214221

222+
/// Drain the "MCP server '…' initialized" lines collected during
223+
/// [`Self::new`] so the caller can splice them into the startup
224+
/// banner.
225+
pub(crate) fn take_mcp_init_lines(&mut self) -> String {
226+
std::mem::take(&mut self.mcp_init_lines)
227+
}
228+
215229
/// Install the interrupt flag used by the TUI to signal ESC/Ctrl+C during
216230
/// an AI turn. Called once before the worker thread takes ownership.
217231
pub fn install_interrupt_flag(&mut self, flag: Arc<AtomicBool>) {

0 commit comments

Comments
 (0)