From d3e8148a4bf21191d6784f7e02f80e0d991d59e9 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:41:05 -0700 Subject: [PATCH 01/41] refactor(base): remove layering inversion - foundation no longer depends on TUI crates jcode-base was a 'foundation' layer that declared 11 jcode-tui-* dependencies plus ratatui/arboard, even though its source used only two pure symbols from them: ResumeTarget (a plain data enum) and reasoning_line_markup (a pure string formatter). The other 9 tui deps were dead edges. Move the two symbols to where they belong: - ResumeTarget -> jcode-session-types (pure data; session-picker re-exports it) - reasoning markup helpers + REASONING_SENTINEL -> jcode-render-core (pure/backend-neutral; tui-markdown re-exports them) Then drop all 11 jcode-tui-* deps + ratatui + arboard from jcode-base and add jcode-render-core. Public paths (jcode_tui_session_picker::ResumeTarget, jcode_tui_markdown::reasoning_line_markup) keep working via re-exports. The foundation no longer depends on any presentation crate, breaking the base->tui->base style layering inversion and shrinking base's compile graph. No behavior change; validated by full jcode binary build + render-core/ session-types/tui-markdown/base import+reasoning tests. --- Cargo.lock | 14 +--- crates/jcode-base/Cargo.toml | 23 +++--- crates/jcode-base/src/import.rs | 18 ++--- crates/jcode-base/src/import_tests.rs | 4 +- crates/jcode-base/src/session/render.rs | 2 +- crates/jcode-base/src/session_tests/cases.rs | 4 +- crates/jcode-render-core/src/lib.rs | 2 + crates/jcode-render-core/src/reasoning.rs | 76 ++++++++++++++++++ crates/jcode-session-types/src/lib.rs | 38 +++++++++ crates/jcode-tui-markdown/src/lib.rs | 82 ++------------------ crates/jcode-tui-session-picker/src/lib.rs | 39 ++-------- 11 files changed, 152 insertions(+), 150 deletions(-) create mode 100644 crates/jcode-render-core/src/reasoning.rs diff --git a/Cargo.lock b/Cargo.lock index 499204bc7..457e789e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3528,7 +3528,6 @@ version = "0.1.0" dependencies = [ "agentgrep", "anyhow", - "arboard", "async-trait", "aws-config", "aws-credential-types", @@ -3577,6 +3576,7 @@ dependencies = [ "jcode-provider-metadata", "jcode-provider-openai", "jcode-provider-openrouter", + "jcode-render-core", "jcode-selfdev-types", "jcode-session-types", "jcode-side-panel-types", @@ -3587,17 +3587,6 @@ dependencies = [ "jcode-terminal-launch", "jcode-tool-core", "jcode-tool-types", - "jcode-tui-account-picker", - "jcode-tui-core", - "jcode-tui-markdown", - "jcode-tui-mermaid", - "jcode-tui-messages", - "jcode-tui-render", - "jcode-tui-session-picker", - "jcode-tui-style", - "jcode-tui-tool-display", - "jcode-tui-usage-overlay", - "jcode-tui-workspace", "jcode-update-core", "jcode-usage-types", "libc", @@ -3605,7 +3594,6 @@ dependencies = [ "proctitle", "qrcode", "rand 0.9.3", - "ratatui", "regex", "reqwest 0.12.28", "rustls 0.23.37", diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index 9a5287ad0..785f294e7 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -80,30 +80,25 @@ jcode-provider-core = { path = "../jcode-provider-core" } jcode-provider-openai = { path = "../jcode-provider-openai" } jcode-provider-openrouter = { path = "../jcode-provider-openrouter" } jcode-provider-gemini = { path = "../jcode-provider-gemini" } -jcode-tui-markdown = { path = "../jcode-tui-markdown" } -jcode-tui-messages = { path = "../jcode-tui-messages" } -jcode-tui-core = { path = "../jcode-tui-core" } -jcode-tui-mermaid = { path = "../jcode-tui-mermaid" } -jcode-tui-account-picker = { path = "../jcode-tui-account-picker" } -jcode-tui-render = { path = "../jcode-tui-render" } -jcode-tui-session-picker = { path = "../jcode-tui-session-picker", features = ["serde"] } -jcode-tui-style = { path = "../jcode-tui-style" } -jcode-tui-tool-display = { path = "../jcode-tui-tool-display" } -jcode-tui-usage-overlay = { path = "../jcode-tui-usage-overlay" } +# NOTE: this foundation layer intentionally does NOT depend on any `jcode-tui-*` +# crate. The two pure-data/string symbols it used to reach for (`ResumeTarget`, +# `reasoning_line_markup`) were moved to `jcode-session-types` and +# `jcode-render-core` respectively, so the layering inversion (foundation +# depending on presentation) is gone. +jcode-render-core = { path = "../jcode-render-core" } jcode-update-core = { path = "../jcode-update-core" } jcode-terminal-launch = { path = "../jcode-terminal-launch" } jcode-terminal-image = { path = "../jcode-terminal-image" } -jcode-tui-workspace = { path = "../jcode-tui-workspace" } jcode-usage-types = { path = "../jcode-usage-types" } # Streaming tokio-stream = "0.1" bytes = "1" -# TUI -ratatui = "0.30" +# Terminal I/O (event stream for interactive auth/secret prompts). NOTE: the +# heavier presentation deps (ratatui widgets, arboard clipboard) were dropped +# from this foundation layer; they are unused here and live only in `jcode-tui`. crossterm = { version = "0.29", features = ["event-stream"] } -arboard = "3" # Clipboard support image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Only PNG/JPEG (skip avif/rav1e, exr, gif, tiff, etc) # Markdown & syntax highlighting diff --git a/crates/jcode-base/src/import.rs b/crates/jcode-base/src/import.rs index 3d8ce85a6..f5eb0f695 100644 --- a/crates/jcode-base/src/import.rs +++ b/crates/jcode-base/src/import.rs @@ -375,31 +375,31 @@ pub fn import_session(session_id: &str) -> Result { } pub fn imported_session_id_for_target( - target: &jcode_tui_session_picker::ResumeTarget, + target: &jcode_session_types::ResumeTarget, ) -> Option { match target { - jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } => { + jcode_session_types::ResumeTarget::JcodeSession { session_id } => { Some(session_id.clone()) } - jcode_tui_session_picker::ResumeTarget::ClaudeCodeSession { session_id, .. } => { + jcode_session_types::ResumeTarget::ClaudeCodeSession { session_id, .. } => { Some(imported_claude_code_session_id(session_id)) } - jcode_tui_session_picker::ResumeTarget::CodexSession { session_id, .. } => { + jcode_session_types::ResumeTarget::CodexSession { session_id, .. } => { Some(imported_codex_session_id(session_id)) } - jcode_tui_session_picker::ResumeTarget::PiSession { session_path } => { + jcode_session_types::ResumeTarget::PiSession { session_path } => { Some(imported_pi_session_id(session_path)) } - jcode_tui_session_picker::ResumeTarget::OpenCodeSession { session_id, .. } => { + jcode_session_types::ResumeTarget::OpenCodeSession { session_id, .. } => { Some(imported_opencode_session_id(session_id)) } } } pub fn resolve_resume_target_to_jcode( - target: &jcode_tui_session_picker::ResumeTarget, -) -> Result { - use jcode_tui_session_picker::ResumeTarget; + target: &jcode_session_types::ResumeTarget, +) -> Result { + use jcode_session_types::ResumeTarget; let session_id = match target { ResumeTarget::JcodeSession { session_id } => { diff --git a/crates/jcode-base/src/import_tests.rs b/crates/jcode-base/src/import_tests.rs index aa292d7ce..e3677ce71 100644 --- a/crates/jcode-base/src/import_tests.rs +++ b/crates/jcode-base/src/import_tests.rs @@ -420,7 +420,7 @@ fn test_resolve_resume_target_to_jcode_imports_codex_session() { .unwrap(); let resolved = - resolve_resume_target_to_jcode(&jcode_tui_session_picker::ResumeTarget::CodexSession { + resolve_resume_target_to_jcode(&jcode_session_types::ResumeTarget::CodexSession { session_id: "codex-resolve-test".to_string(), session_path: codex_dir .join("rollout.jsonl") @@ -431,7 +431,7 @@ fn test_resolve_resume_target_to_jcode_imports_codex_session() { assert_eq!( resolved, - jcode_tui_session_picker::ResumeTarget::JcodeSession { + jcode_session_types::ResumeTarget::JcodeSession { session_id: imported_codex_session_id("codex-resolve-test"), } ); diff --git a/crates/jcode-base/src/session/render.rs b/crates/jcode-base/src/session/render.rs index a9c909d8f..fd067efe3 100644 --- a/crates/jcode-base/src/session/render.rs +++ b/crates/jcode-base/src/session/render.rs @@ -22,7 +22,7 @@ fn format_reasoning_markup(text: &str) -> String { } let mut out = String::new(); for line in text.split('\n') { - out.push_str(&jcode_tui_markdown::reasoning_line_markup(line)); + out.push_str(&jcode_render_core::reasoning_line_markup(line)); } // Blank line terminates the reasoning block. out.push('\n'); diff --git a/crates/jcode-base/src/session_tests/cases.rs b/crates/jcode-base/src/session_tests/cases.rs index 3aebf4bbe..854a32c2c 100644 --- a/crates/jcode-base/src/session_tests/cases.rs +++ b/crates/jcode-base/src/session_tests/cases.rs @@ -1059,7 +1059,7 @@ fn test_render_messages_honors_system_display_role_override() { #[test] fn test_render_messages_renders_persisted_reasoning() { - use jcode_tui_markdown::REASONING_SENTINEL; + use jcode_render_core::REASONING_SENTINEL; let mut session = Session::create_with_id( "session_render_reasoning_test".to_string(), @@ -1104,7 +1104,7 @@ fn test_render_messages_renders_persisted_reasoning() { #[test] fn test_render_messages_renders_legacy_reasoning_variant() { - use jcode_tui_markdown::REASONING_SENTINEL; + use jcode_render_core::REASONING_SENTINEL; let mut session = Session::create_with_id( "session_render_legacy_reasoning_test".to_string(), diff --git a/crates/jcode-render-core/src/lib.rs b/crates/jcode-render-core/src/lib.rs index e1716d489..30d88305e 100644 --- a/crates/jcode-render-core/src/lib.rs +++ b/crates/jcode-render-core/src/lib.rs @@ -22,10 +22,12 @@ pub mod markdown; pub mod model; pub mod preprocess; +pub mod reasoning; pub mod wrap; pub use markdown::parse_markdown; pub use preprocess::escape_currency_dollars; +pub use reasoning::{REASONING_SENTINEL, reasoning_line_markup, reasoning_partial_markup}; pub use model::{ Alignment, Block, BlockKind, Document, FillRole, StyleRole, StyledLine, StyledSpan, TextAttrs, }; diff --git a/crates/jcode-render-core/src/reasoning.rs b/crates/jcode-render-core/src/reasoning.rs new file mode 100644 index 000000000..7dda81d7f --- /dev/null +++ b/crates/jcode-render-core/src/reasoning.rs @@ -0,0 +1,76 @@ +//! Reasoning-line markdown formatting. +//! +//! Pure string helpers shared by the server/streaming path and the TUI renderer +//! so the wrapping/escaping rules stay in lockstep with the renderer that +//! consumes them. These live in `jcode-render-core` (a backend-neutral, pure +//! crate) rather than in `jcode-tui-markdown` so the foundation/streaming layer +//! can format reasoning lines without depending on any `jcode-tui-*` crate. + +/// Invisible separator placed just inside both ends of an emphasis run so the +/// flanking `*` are always adjacent to non-whitespace (see +/// [`reasoning_line_markup`]). +pub const REASONING_SENTINEL: &str = "\u{2063}"; + +/// Escape the characters that would otherwise be interpreted as inline markdown +/// inside a reasoning line, so the body renders literally inside the dim/italic +/// emphasis run. +fn escape_reasoning_inline_markdown(line: &str) -> String { + let mut out = String::with_capacity(line.len() + 8); + for ch in line.chars() { + match ch { + '\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>' | '&' | '~' | '|' | '$' => { + out.push('\\'); + out.push(ch); + } + _ => out.push(ch), + } + } + out +} + +/// Wrap a completed reasoning line as dim+italic markdown. +/// +/// Empty lines become a bare newline (no empty emphasis run). The result always +/// ends in a CommonMark hard break (`" \n"`). +/// +/// The trailing two spaces are a CommonMark *hard break*: without them, +/// consecutive reasoning lines (each terminated by a single `\n`) collapse into +/// one paragraph where the line breaks render as spaces, so multi-line thinking +/// shows up as a single run-on line. The hard break keeps each reasoning line on +/// its own visual row, matching the model's line structure. +/// +/// The sentinel must wrap both ends because CommonMark's emphasis flanking rules +/// require the opening `*` to not be followed by whitespace and the closing `*` +/// to not be preceded by whitespace. A reasoning line that starts or ends with +/// whitespace (or is whitespace-only) would otherwise leave the asterisks as +/// literal text and break the dim/italic styling. The zero-width sentinels +/// guarantee both asterisks are flanked by non-whitespace regardless of the body. +pub fn reasoning_line_markup(line: &str) -> String { + if line.is_empty() { + "\n".to_string() + } else { + format!( + "*{0}{1}{0}* \n", + REASONING_SENTINEL, + escape_reasoning_inline_markdown(line) + ) + } +} + +/// Wrap the in-progress (not yet newline-terminated) reasoning line as dim+italic +/// markdown, identical to [`reasoning_line_markup`] but *without* the trailing +/// newline so it renders as the live tail of the streaming buffer. Callers +/// truncate and re-emit this tail on each streamed delta so reasoning trickles in +/// token-by-token instead of one whole line at a time. An empty line yields an +/// empty string (nothing to render yet). +pub fn reasoning_partial_markup(line: &str) -> String { + if line.is_empty() { + String::new() + } else { + format!( + "*{0}{1}{0}*", + REASONING_SENTINEL, + escape_reasoning_inline_markdown(line) + ) + } +} diff --git a/crates/jcode-session-types/src/lib.rs b/crates/jcode-session-types/src/lib.rs index f0472eec3..0116fb628 100644 --- a/crates/jcode-session-types/src/lib.rs +++ b/crates/jcode-session-types/src/lib.rs @@ -3,6 +3,44 @@ use jcode_message_types::{ContentBlock, Message, Role, ToolCall}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; +/// Identifies a session to resume, across the agent backends jcode can import +/// from. This is pure data (only ids/paths) with no UI dependency; it lives in +/// `jcode-session-types` so the foundation/import layer can match on it without +/// depending on any `jcode-tui-*` crate. The session-picker UI re-exports it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResumeTarget { + JcodeSession { + session_id: String, + }, + ClaudeCodeSession { + session_id: String, + session_path: String, + }, + CodexSession { + session_id: String, + session_path: String, + }, + PiSession { + session_path: String, + }, + OpenCodeSession { + session_id: String, + session_path: String, + }, +} + +impl ResumeTarget { + pub fn stable_id(&self) -> &str { + match self { + Self::JcodeSession { session_id } => session_id, + Self::ClaudeCodeSession { session_id, .. } => session_id, + Self::CodexSession { session_id, .. } => session_id, + Self::PiSession { session_path } => session_path, + Self::OpenCodeSession { session_id, .. } => session_id, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RenderedMessage { pub role: String, diff --git a/crates/jcode-tui-markdown/src/lib.rs b/crates/jcode-tui-markdown/src/lib.rs index 0d9a71335..225e7b92c 100644 --- a/crates/jcode-tui-markdown/src/lib.rs +++ b/crates/jcode-tui-markdown/src/lib.rs @@ -108,82 +108,14 @@ pub use render_full::render_markdown_with_width; pub use render_lazy::render_markdown_lazy; pub use render_support::extract_copy_targets_from_rendered_lines; -/// Invisible sentinel prepended (inside `*…*`) to streamed reasoning/thinking -/// lines. The renderer strips it and styles the line dim + italic with no -/// blockquote gutter. Kept zero-width so it stays invisible if it ever leaks -/// into copied text. Shared with the TUI/server reasoning formatters. -pub const REASONING_SENTINEL: &str = "\u{2063}"; - -/// Escape characters that pulldown-cmark would otherwise interpret as inline -/// markdown (emphasis, code, links, etc.). Reasoning lines are rendered inside a -/// single `*…*` emphasis run; without escaping, a stray `*`, `` ` ``, `[`, or -/// `_` in the model's thinking would prematurely close or nest that run and the -/// dim/italic styling would break partway through the line. -fn escape_reasoning_inline_markdown(line: &str) -> String { - let mut out = String::with_capacity(line.len() + 8); - for ch in line.chars() { - match ch { - '\\' | '*' | '_' | '`' | '[' | ']' | '<' | '>' | '&' | '~' | '|' | '$' => { - out.push('\\'); - out.push(ch); - } - _ => out.push(ch), - } - } - out -} - -/// Wrap one complete reasoning/thinking line as dim+italic markdown: the -/// invisible [`REASONING_SENTINEL`] is placed just inside *both* ends of an -/// `*…*` emphasis run that the renderer strips and styles dim, with no -/// blockquote gutter. The line body is escaped so embedded markdown cannot break -/// the styling. Empty lines become a bare newline (no empty emphasis run). The -/// result always ends in a CommonMark hard break (`" \n"`). -/// -/// The trailing two spaces are a CommonMark *hard break*: without them, -/// consecutive reasoning lines (each terminated by a single `\n`) collapse into -/// one paragraph where the line breaks render as spaces, so multi-line thinking -/// shows up as a single run-on line. The hard break keeps each reasoning line on -/// its own visual row, matching the model's line structure. -/// -/// The sentinel must wrap both ends because CommonMark's emphasis flanking rules -/// require the opening `*` to not be followed by whitespace and the closing `*` -/// to not be preceded by whitespace. A reasoning line that starts or ends with -/// whitespace (or is whitespace-only) would otherwise leave the asterisks as -/// literal text and break the dim/italic styling. The zero-width sentinels -/// guarantee both asterisks are flanked by non-whitespace regardless of the body. +/// Reasoning-line markdown formatters and the zero-width sentinel they use. /// -/// Shared by the server and TUI reasoning formatters so the wrapping/escaping -/// rules stay in lockstep with the renderer that consumes them. -pub fn reasoning_line_markup(line: &str) -> String { - if line.is_empty() { - "\n".to_string() - } else { - format!( - "*{0}{1}{0}* \n", - REASONING_SENTINEL, - escape_reasoning_inline_markdown(line) - ) - } -} - -/// Wrap the in-progress (not yet newline-terminated) reasoning line as dim+italic -/// markdown, identical to [`reasoning_line_markup`] but *without* the trailing -/// newline so it renders as the live tail of the streaming buffer. Callers -/// truncate and re-emit this tail on each streamed delta so reasoning trickles in -/// token-by-token instead of one whole line at a time. An empty line yields an -/// empty string (nothing to render yet). -pub fn reasoning_partial_markup(line: &str) -> String { - if line.is_empty() { - String::new() - } else { - format!( - "*{0}{1}{0}*", - REASONING_SENTINEL, - escape_reasoning_inline_markdown(line) - ) - } -} +/// These pure-string helpers were moved to `jcode-render-core` so the +/// foundation/streaming layer can format reasoning without depending on any +/// `jcode-tui-*` crate. Re-exported here so existing +/// `jcode_tui_markdown::{reasoning_line_markup, reasoning_partial_markup, +/// REASONING_SENTINEL}` paths keep working. +pub use jcode_render_core::{REASONING_SENTINEL, reasoning_line_markup, reasoning_partial_markup}; use render_support::{ highlight_code_cached, line_plain_text, placeholder_code_block, ranges_overlap, render_table, diff --git a/crates/jcode-tui-session-picker/src/lib.rs b/crates/jcode-tui-session-picker/src/lib.rs index d3deb23d8..cebe3e4d6 100644 --- a/crates/jcode-tui-session-picker/src/lib.rs +++ b/crates/jcode-tui-session-picker/src/lib.rs @@ -24,40 +24,11 @@ impl SessionSource { } } -#[derive(Clone, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub enum ResumeTarget { - JcodeSession { - session_id: String, - }, - ClaudeCodeSession { - session_id: String, - session_path: String, - }, - CodexSession { - session_id: String, - session_path: String, - }, - PiSession { - session_path: String, - }, - OpenCodeSession { - session_id: String, - session_path: String, - }, -} - -impl ResumeTarget { - pub fn stable_id(&self) -> &str { - match self { - Self::JcodeSession { session_id } => session_id, - Self::ClaudeCodeSession { session_id, .. } => session_id, - Self::CodexSession { session_id, .. } => session_id, - Self::PiSession { session_path } => session_path, - Self::OpenCodeSession { session_id, .. } => session_id, - } - } -} +// `ResumeTarget` is pure data and now lives in `jcode-session-types` so the +// foundation/import layer can use it without depending on this UI crate. It is +// re-exported here so existing `jcode_tui_session_picker::ResumeTarget` paths +// keep working. +pub use jcode_session_types::ResumeTarget; #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] From ee4f19af30f103b144cb7d7829e0f653b0b1f863 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:43:25 -0700 Subject: [PATCH 02/41] refactor(app-core): drop 11 dead jcode-tui-* dependency edges jcode-app-core declared all 11 jcode-tui-* crates but used none of them in its source (verified: 0 references), and did not re-export them. They were dead edges left over from the base/app-core/tui split. The TUI crate declares these deps itself. Removing them means editing a jcode-tui-* crate no longer cascades a recompile through app-core (measured: touching jcode-tui-style now rebuilds only tui-style -> tui -> jcode, ~6s, instead of also rebuilding base+app-core). No behavior change; full jcode binary builds clean. --- Cargo.lock | 11 ----------- crates/jcode-app-core/Cargo.toml | 14 +++----------- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 457e789e1..ada9a06ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3457,17 +3457,6 @@ dependencies = [ "jcode-terminal-launch", "jcode-tool-core", "jcode-tool-types", - "jcode-tui-account-picker", - "jcode-tui-core", - "jcode-tui-markdown", - "jcode-tui-mermaid", - "jcode-tui-messages", - "jcode-tui-render", - "jcode-tui-session-picker", - "jcode-tui-style", - "jcode-tui-tool-display", - "jcode-tui-usage-overlay", - "jcode-tui-workspace", "jcode-update-core", "jcode-usage-types", "libc", diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index 2046e9a91..e0617dc7f 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -78,20 +78,12 @@ jcode-provider-core = { path = "../jcode-provider-core" } jcode-provider-openai = { path = "../jcode-provider-openai" } jcode-provider-openrouter = { path = "../jcode-provider-openrouter" } jcode-provider-gemini = { path = "../jcode-provider-gemini" } -jcode-tui-markdown = { path = "../jcode-tui-markdown" } -jcode-tui-messages = { path = "../jcode-tui-messages" } -jcode-tui-core = { path = "../jcode-tui-core" } -jcode-tui-mermaid = { path = "../jcode-tui-mermaid" } -jcode-tui-account-picker = { path = "../jcode-tui-account-picker" } -jcode-tui-render = { path = "../jcode-tui-render" } -jcode-tui-session-picker = { path = "../jcode-tui-session-picker", features = ["serde"] } -jcode-tui-style = { path = "../jcode-tui-style" } -jcode-tui-tool-display = { path = "../jcode-tui-tool-display" } -jcode-tui-usage-overlay = { path = "../jcode-tui-usage-overlay" } +# NOTE: jcode-app-core does NOT depend on any jcode-tui-* crate. They were +# unused dead dependency edges here (the TUI declares them itself). Removing +# them stops a jcode-tui-* edit from cascading a recompile through app-core. jcode-update-core = { path = "../jcode-update-core" } jcode-terminal-launch = { path = "../jcode-terminal-launch" } jcode-terminal-image = { path = "../jcode-terminal-image" } -jcode-tui-workspace = { path = "../jcode-tui-workspace" } jcode-usage-types = { path = "../jcode-usage-types" } # Streaming From 08846bae48d17e1ea746474d545c6c41d1938d3b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:47:38 -0700 Subject: [PATCH 03/41] fix(release): embed git metadata in Linux compat build so /changelog is populated The portable Linux release is built inside a Docker container that bind-mounts the repo (owned by the host UID) and runs git as root. Every in-container git call trips git's dubious-ownership guard (CVE-2022-24765) and fails, which zeroed out the embedded git hash, date, AND changelog. Shipped binaries reported "vX.Y.Z (unknown) (unknown)" with an empty /changelog overlay. Compute git hash/date/tag/dirty/changelog_raw on the host and pass them into the container via JCODE_BUILD_METADATA_FILE (same mechanism as remote_build.sh), and add 'git config --global --add safe.directory /work' as a fallback. Verified: with git failing in-container the metadata file alone now embeds 700 changelog entries and the correct version string. --- scripts/build_linux_compat.sh | 47 +++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/scripts/build_linux_compat.sh b/scripts/build_linux_compat.sh index b68ad8f9c..9d1d08bf5 100755 --- a/scripts/build_linux_compat.sh +++ b/scripts/build_linux_compat.sh @@ -31,18 +31,58 @@ mkdir -p "$out_dir" \ host_uid="$(id -u)" host_gid="$(id -g)" +# Compute git build metadata on the HOST and hand it to the container via a +# metadata file (read by jcode-build-meta/build.rs through +# JCODE_BUILD_METADATA_FILE). The repo is bind-mounted into the container and +# owned by the host UID while git inside the container runs as root, so any +# in-container `git` call trips git's "dubious ownership" guard +# (CVE-2022-24765) and fails. That previously zeroed out the embedded git hash, +# date, AND changelog, shipping release binaries that report +# "vX.Y.Z (unknown) (unknown)" with an empty /changelog overlay. Computing the +# values here makes the embedded metadata independent of container-git. This +# mirrors scripts/remote_build.sh. +git_hash="" +git_date="" +git_tag="" +git_dirty="0" +changelog_raw="" +if command -v git >/dev/null 2>&1 && git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1; then + git_hash="$(git -C "$repo_root" rev-parse --short HEAD 2>/dev/null || true)" + git_date="$(git -C "$repo_root" log -1 --format=%ci 2>/dev/null || true)" + git_tag="$(git -C "$repo_root" describe --tags --always 2>/dev/null || true)" + changelog_raw="$(git -C "$repo_root" log -700 --format='%h|%ct|%D|%s' 2>/dev/null || true)" + if [[ -n "$(git -C "$repo_root" status --porcelain 2>/dev/null || true)" ]]; then + git_dirty="1" + fi +else + echo "warning: git metadata unavailable on host; embedded changelog/version may be empty" >&2 +fi + +metadata_file="$(mktemp)" +trap 'rm -f "$metadata_file"' EXIT +{ + printf 'git_hash=%s\n' "$git_hash" + printf 'git_date=%s\n' "$git_date" + printf 'git_tag=%s\n' "$git_tag" + printf 'git_dirty=%s\n' "$git_dirty" + printf 'changelog_raw< "$metadata_file" + echo "Building portable Linux release in Docker image: $image" echo "Output dir: $out_dir" +echo "Embedding git metadata: hash=${git_hash:-} tag=${git_tag:-} dirty=$git_dirty changelog_lines=$(printf '%s' "$changelog_raw" | grep -c '' || true)" docker run --rm \ -e CARGO_TERM_COLOR=always \ -e JCODE_RELEASE_BUILD="${JCODE_RELEASE_BUILD:-1}" \ -e JCODE_BUILD_SEMVER="${JCODE_BUILD_SEMVER:-}" \ + -e JCODE_BUILD_METADATA_FILE=/jcode-build-meta \ -e JCODE_COMPAT_PROFILE="$profile" \ -e JCODE_COMPAT_TARGET="$target" \ -e HOST_UID="$host_uid" \ -e HOST_GID="$host_gid" \ -v "$repo_root:/work" \ + -v "$metadata_file:/jcode-build-meta:ro" \ -v "$out_dir:/out" \ -v "$cache_root/cargo-registry:/root/.cargo/registry" \ -v "$cache_root/cargo-git:/root/.cargo/git" \ @@ -84,6 +124,13 @@ docker run --rm \ fi source /root/.cargo/env + # Belt-and-suspenders: the host-computed metadata file + # (JCODE_BUILD_METADATA_FILE=/jcode-build-meta) is the primary source of + # git hash/date/changelog, but mark the bind-mounted repo as a safe + # directory so any in-container git fallback still works despite the + # host-UID/root-git ownership mismatch (CVE-2022-24765 guard). + git config --global --add safe.directory /work 2>/dev/null || true + export CARGO_TARGET_DIR=/work/target/linux-compat export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-1}" export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="${CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS:--C link-arg=-static-libgcc}" From 240a7f0a6f0c4a809b7e86318b60a1feb8040b07 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:40:21 -0700 Subject: [PATCH 04/41] refactor(auth): single source of truth for Claude/OpenAI OAuth-vs-API decision The OAuth-vs-API-key decision for the two dual-auth providers (Anthropic/ Claude and OpenAI) was encoded as free-form strings across four overlapping vocabularies (runtime env `JCODE_RUNTIME_PROVIDER`, route stable-id, CLI `--provider`, and model prefix). ~15 call sites each hand-maintained their own subset of aliases, and those subsets drifted: - `OpenAICredentialMode::from_runtime_env` accepted `openai` but not the route-vocabulary `openai-oauth`. - `AnthropicCredentialMode::from_runtime_env` accepted neither `claude-oauth` nor `anthropic-api-key`. - pricing, billing, header tag, session-key folding, swarm-spawn routing, and CLI-arg translation each re-derived the decision independently. When a string from one vocabulary leaked into a parser that only knew another, OAuth and API-key paths got mixed up. This adds `jcode_provider_core::auth_mode` as the canonical source of truth: `AuthRoute { provider, mode }` parses *any* alias from *any* vocabulary and emits the canonical string for each vocabulary (runtime key, route api_method, model prefix, session key, CLI arg). Every scattered parser/emitter now routes through it: - `ModelRouteApiMethod::parse` delegates its dual-auth cases. - Anthropic/OpenAI `from_runtime_env` + credential-mode writers. - `resolve_dual_credential_auth` (header tag / info widget / model-switch line). - `update_cost_impl` billing decision. - `cli_provider_arg_for_session_key`, `canonical_session_provider_key`, `explicit_session_provider_key_for_model_request`, `model_switch_request_for_session_model`, `fork_model_switch_request`. - `cheapness_for_route` pricing and swarm `explicit_route_for_configured_model`. `parse_explicit_credential_prefix` preserves the model-prefix nuance that bare `claude:`/`openai:` route without pinning a credential (vs runtime/CLI where bare `claude` means OAuth). Also fixes a stale test that asserted Anthropic Auto was API-key-first; the codebase consistently implements OAuth-first Auto (matching OpenAI and `resolve_dual_credential_auth`). Adds cross-vocabulary round-trip tests proving every alias resolves consistently and agrees with `ModelRouteApiMethod`. --- .../jcode-app-core/src/server/comm_session.rs | 16 +- crates/jcode-base/src/auth/active_method.rs | 41 +- crates/jcode-base/src/provider/anthropic.rs | 38 +- crates/jcode-base/src/provider/mod.rs | 56 ++- crates/jcode-base/src/provider/openai.rs | 38 +- crates/jcode-base/src/provider/pricing.rs | 44 +- crates/jcode-base/src/provider/selection.rs | 33 +- .../src/provider/tests/model_resolution.rs | 6 +- crates/jcode-provider-core/src/auth_mode.rs | 430 ++++++++++++++++++ crates/jcode-provider-core/src/lib.rs | 26 +- crates/jcode-provider-core/src/selection.rs | 9 +- crates/jcode-tui/src/tui/app/misc_ui.rs | 18 +- 12 files changed, 628 insertions(+), 127 deletions(-) create mode 100644 crates/jcode-provider-core/src/auth_mode.rs diff --git a/crates/jcode-app-core/src/server/comm_session.rs b/crates/jcode-app-core/src/server/comm_session.rs index 6071ba691..7cb032462 100644 --- a/crates/jcode-app-core/src/server/comm_session.rs +++ b/crates/jcode-app-core/src/server/comm_session.rs @@ -246,16 +246,12 @@ fn explicit_route_for_configured_model(model: &str) -> Option "openai-api-key", - "openai-oauth:" => "openai-oauth", - "claude-api:" => "anthropic-api-key", - "claude-oauth:" => "claude-oauth", - _ => return None, - }; + // Only the dual-auth (Anthropic/OpenAI OAuth-vs-API) prefixes carry an + // explicit credential decision worth pinning. The canonical parser maps the + // prefix to its stable route id, which `ModelRouteApiMethod::parse` round- + // trips back to the exact auth method when the spawned session is restored. + let route_id = jcode_provider_core::AuthRoute::parse_explicit_credential_prefix(prefix)? + .route_api_method(); Some(SwarmSpawnSelection { model: Some(bare.to_string()), provider_key: Some(route_id.to_string()), diff --git a/crates/jcode-base/src/auth/active_method.rs b/crates/jcode-base/src/auth/active_method.rs index 7e7389419..74dd90f79 100644 --- a/crates/jcode-base/src/auth/active_method.rs +++ b/crates/jcode-base/src/auth/active_method.rs @@ -72,33 +72,32 @@ pub fn resolve_dual_credential_auth( auth: &AuthStatus, runtime_provider: Option<&str>, ) -> Option { - let runtime = runtime_provider.map(|value| value.trim().to_ascii_lowercase()); - - let (has_oauth, has_api_key, forced) = match provider { - ActiveProvider::Claude => { + // Map the execution slot onto the canonical dual-auth provider. Anything + // without an OAuth-vs-API decision (Copilot, Gemini, ...) returns None. + let dual = jcode_provider_core::DualAuthProvider::from_active_provider(provider)?; + + // A single canonical parser decides whether `runtime_provider` explicitly + // pins OAuth or API key for *this* provider. This replaces the per-provider + // hand-written alias matches that used to drift apart. + let forced = jcode_provider_core::pinned_mode_for(dual, runtime_provider).map(|mode| match mode + { + jcode_provider_core::AuthMode::Oauth => ActiveCredential::OAuth, + jcode_provider_core::AuthMode::ApiKey => ActiveCredential::ApiKey, + }); + + let (has_oauth, has_api_key) = match dual { + jcode_provider_core::DualAuthProvider::Anthropic => { let has_oauth = auth.anthropic.has_oauth; // `has_api_key` already folds in the ANTHROPIC_API_KEY env var via the // auth probe, but re-check defensively so an env-only key set after the // cached snapshot still reports honestly. - let has_api_key = auth.anthropic.has_api_key || std::env::var("ANTHROPIC_API_KEY").is_ok(); - let forced = match runtime.as_deref() { - Some("claude-api" | "anthropic-api") => Some(ActiveCredential::ApiKey), - Some("claude" | "anthropic") => Some(ActiveCredential::OAuth), - _ => None, - }; - (has_oauth, has_api_key, forced) + let has_api_key = + auth.anthropic.has_api_key || std::env::var("ANTHROPIC_API_KEY").is_ok(); + (has_oauth, has_api_key) } - ActiveProvider::OpenAI => { - let has_oauth = auth.openai_has_oauth; - let has_api_key = auth.openai_has_api_key; - let forced = match runtime.as_deref() { - Some("openai-api") => Some(ActiveCredential::ApiKey), - Some("openai") => Some(ActiveCredential::OAuth), - _ => None, - }; - (has_oauth, has_api_key, forced) + jcode_provider_core::DualAuthProvider::OpenAI => { + (auth.openai_has_oauth, auth.openai_has_api_key) } - _ => return None, }; let active = match forced { diff --git a/crates/jcode-base/src/provider/anthropic.rs b/crates/jcode-base/src/provider/anthropic.rs index a323d6be8..d632f63d5 100644 --- a/crates/jcode-base/src/provider/anthropic.rs +++ b/crates/jcode-base/src/provider/anthropic.rs @@ -415,14 +415,26 @@ pub(crate) enum AnthropicCredentialMode { impl AnthropicCredentialMode { fn from_runtime_env() -> Self { - match std::env::var("JCODE_RUNTIME_PROVIDER") - .ok() - .map(|value| value.trim().to_ascii_lowercase()) - .as_deref() - { - Some("claude-api" | "anthropic-api") => Self::ApiKey, - Some("claude" | "anthropic") => Self::OAuth, - _ => Self::Auto, + // Canonical parse: recognizes every runtime/route/CLI/prefix alias for + // the Anthropic OAuth-vs-API decision in one place, so this can never + // drift from the other vocabularies (see jcode_provider_core::auth_mode). + match jcode_provider_core::runtime_env_pinned_mode( + jcode_provider_core::DualAuthProvider::Anthropic, + ) { + Some(jcode_provider_core::AuthMode::ApiKey) => Self::ApiKey, + Some(jcode_provider_core::AuthMode::Oauth) => Self::OAuth, + None => Self::Auto, + } + } + + /// The canonical dual-auth route this explicit mode pins, if any. + /// `Auto` has no explicit pin and returns `None`. + pub(crate) fn auth_route(self) -> Option { + use jcode_provider_core::{AuthMode, AuthRoute}; + match self { + Self::Auto => None, + Self::OAuth => Some(AuthRoute::anthropic(AuthMode::Oauth)), + Self::ApiKey => Some(AuthRoute::anthropic(AuthMode::ApiKey)), } } } @@ -844,14 +856,8 @@ impl AnthropicProvider { // choice so UI surfaces (model picker, header widget) report the auth // method that requests will actually use, instead of inferring it from // credential presence. `Auto` leaves the existing identity untouched. - match mode { - AnthropicCredentialMode::OAuth => { - crate::env::set_var("JCODE_RUNTIME_PROVIDER", "claude"); - } - AnthropicCredentialMode::ApiKey => { - crate::env::set_var("JCODE_RUNTIME_PROVIDER", "claude-api"); - } - AnthropicCredentialMode::Auto => {} + if let Some(route) = mode.auth_route() { + crate::env::set_var("JCODE_RUNTIME_PROVIDER", route.runtime_provider_key()); } Ok(()) } diff --git a/crates/jcode-base/src/provider/mod.rs b/crates/jcode-base/src/provider/mod.rs index 633575798..0a9b2d7d2 100644 --- a/crates/jcode-base/src/provider/mod.rs +++ b/crates/jcode-base/src/provider/mod.rs @@ -934,22 +934,24 @@ impl MultiProvider { let prefix = match active { ActiveProvider::Claude => { if let Some(anthropic) = self.anthropic_provider() { - match anthropic.credential_mode_snapshot() { - anthropic::AnthropicCredentialMode::OAuth => "claude-oauth", - anthropic::AnthropicCredentialMode::ApiKey => "claude-api", - anthropic::AnthropicCredentialMode::Auto => "claude", - } + // OAuth/ApiKey emit their canonical model prefix; Auto keeps + // the bare provider key (route without pinning a credential). + anthropic + .credential_mode_snapshot() + .auth_route() + .map(|route| route.model_prefix()) + .unwrap_or("claude") } else { "claude" } } ActiveProvider::OpenAI => { if let Some(openai) = self.openai_provider() { - match openai.credential_mode_snapshot() { - openai::OpenAICredentialMode::OAuth => "openai-oauth", - openai::OpenAICredentialMode::ApiKey => "openai-api", - openai::OpenAICredentialMode::Auto => "openai", - } + openai + .credential_mode_snapshot() + .auth_route() + .map(|route| route.model_prefix()) + .unwrap_or("openai") } else { "openai" } @@ -1197,16 +1199,30 @@ impl Provider for MultiProvider { explicit_model_provider_prefix(requested_model) { self.ensure_provider_lock_allows_model_target(target, requested_model)?; - let openai_credential_mode = match prefix { - "openai-api:" => Some(openai::OpenAICredentialMode::ApiKey), - "openai-oauth:" => Some(openai::OpenAICredentialMode::OAuth), - _ => None, - }; - let anthropic_credential_mode = match prefix { - "claude-api:" => Some(anthropic::AnthropicCredentialMode::ApiKey), - "claude-oauth:" => Some(anthropic::AnthropicCredentialMode::OAuth), - _ => None, - }; + // The single canonical parser decides whether this prefix pins a + // dual-auth credential (and which provider/mode). Bare `claude:` / + // `openai:` prefixes route without pinning a credential. + let pinned = jcode_provider_core::AuthRoute::parse_explicit_credential_prefix(prefix); + let openai_credential_mode = pinned.and_then(|route| { + matches!(route.provider, jcode_provider_core::DualAuthProvider::OpenAI).then( + || match route.mode { + jcode_provider_core::AuthMode::ApiKey => openai::OpenAICredentialMode::ApiKey, + jcode_provider_core::AuthMode::Oauth => openai::OpenAICredentialMode::OAuth, + }, + ) + }); + let anthropic_credential_mode = pinned.and_then(|route| { + matches!(route.provider, jcode_provider_core::DualAuthProvider::Anthropic).then( + || match route.mode { + jcode_provider_core::AuthMode::ApiKey => { + anthropic::AnthropicCredentialMode::ApiKey + } + jcode_provider_core::AuthMode::Oauth => { + anthropic::AnthropicCredentialMode::OAuth + } + }, + ) + }); if openai_credential_mode.is_some() || anthropic_credential_mode.is_some() { return self.set_model_on_provider_with_credential_modes( target, diff --git a/crates/jcode-base/src/provider/openai.rs b/crates/jcode-base/src/provider/openai.rs index f2d2babd7..596234d58 100644 --- a/crates/jcode-base/src/provider/openai.rs +++ b/crates/jcode-base/src/provider/openai.rs @@ -183,14 +183,26 @@ pub(crate) enum OpenAICredentialMode { impl OpenAICredentialMode { fn from_runtime_env() -> Self { - match std::env::var("JCODE_RUNTIME_PROVIDER") - .ok() - .map(|value| value.trim().to_ascii_lowercase()) - .as_deref() - { - Some("openai-api") => Self::ApiKey, - Some("openai") => Self::OAuth, - _ => Self::Auto, + // Canonical parse: recognizes every runtime/route/CLI/prefix alias for + // the OpenAI OAuth-vs-API decision in one place, so this can never drift + // from the other vocabularies (see jcode_provider_core::auth_mode). + match jcode_provider_core::runtime_env_pinned_mode( + jcode_provider_core::DualAuthProvider::OpenAI, + ) { + Some(jcode_provider_core::AuthMode::ApiKey) => Self::ApiKey, + Some(jcode_provider_core::AuthMode::Oauth) => Self::OAuth, + None => Self::Auto, + } + } + + /// The canonical dual-auth route this explicit mode pins, if any. + /// `Auto` has no explicit pin and returns `None`. + pub(crate) fn auth_route(self) -> Option { + use jcode_provider_core::{AuthMode, AuthRoute}; + match self { + Self::Auto => None, + Self::OAuth => Some(AuthRoute::openai(AuthMode::Oauth)), + Self::ApiKey => Some(AuthRoute::openai(AuthMode::ApiKey)), } } @@ -688,14 +700,8 @@ impl OpenAIProvider { // Keep the runtime provider identity in sync with the explicit credential // choice so UI surfaces report the auth method requests will actually use. // `Auto` leaves the existing identity untouched. - match mode { - OpenAICredentialMode::OAuth => { - crate::env::set_var("JCODE_RUNTIME_PROVIDER", "openai"); - } - OpenAICredentialMode::ApiKey => { - crate::env::set_var("JCODE_RUNTIME_PROVIDER", "openai-api"); - } - OpenAICredentialMode::Auto => {} + if let Some(route) = mode.auth_route() { + crate::env::set_var("JCODE_RUNTIME_PROVIDER", route.runtime_provider_key()); } Ok(()) } diff --git a/crates/jcode-base/src/provider/pricing.rs b/crates/jcode-base/src/provider/pricing.rs index c2fa5df92..9cc3eb3e7 100644 --- a/crates/jcode-base/src/provider/pricing.rs +++ b/crates/jcode-base/src/provider/pricing.rs @@ -111,21 +111,39 @@ pub(crate) fn cheapness_for_route( provider: &str, api_method: &str, ) -> Option { - match api_method { - "claude-oauth" => Some(anthropic_oauth_pricing(model)), - "api-key" | "claude-api" | "anthropic-api-key" if provider == "Anthropic" => { - anthropic_api_pricing(model) - } - "openai-api-key" => { - Some(openai_api_pricing(model).unwrap_or_else(|| openai_oauth_pricing(model))) - } - "openai-oauth" => { - if openai_effective_auth_mode() == "api-key" { + use jcode_provider_core::{AuthMode, AuthRoute, DualAuthProvider}; + + // Dual-auth (Anthropic/OpenAI OAuth-vs-API) methods are recognized through + // the single shared parser so pricing never disagrees with the routing + // layer about whether a route is subscription (OAuth) or metered (API key). + if let Some(route) = AuthRoute::parse(api_method) { + return match (route.provider, route.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => Some(anthropic_oauth_pricing(model)), + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => { + // Bare `api-key` only means Anthropic when the route's provider + // label says so; otherwise fall through to the non-dual arms. + if provider == "Anthropic" { + anthropic_api_pricing(model) + } else { + None + } + } + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => { Some(openai_api_pricing(model).unwrap_or_else(|| openai_oauth_pricing(model))) - } else { - Some(openai_oauth_pricing(model)) } - } + (DualAuthProvider::OpenAI, AuthMode::Oauth) => { + // An "OAuth" route still bills per token when only an API key is + // actually configured, so honor the live effective auth mode. + if openai_effective_auth_mode() == "api-key" { + Some(openai_api_pricing(model).unwrap_or_else(|| openai_oauth_pricing(model))) + } else { + Some(openai_oauth_pricing(model)) + } + } + }; + } + + match api_method { "copilot" => Some(copilot_pricing(model)), "openrouter" => { let model_id = if model.contains('/') { diff --git a/crates/jcode-base/src/provider/selection.rs b/crates/jcode-base/src/provider/selection.rs index 40a9ac453..fb3ded75d 100644 --- a/crates/jcode-base/src/provider/selection.rs +++ b/crates/jcode-base/src/provider/selection.rs @@ -211,13 +211,13 @@ impl MultiProvider { /// child/forked session) still reconstructs the Anthropic API-key route /// instead of falling through to Auto (which prefers OAuth). pub(crate) fn canonical_session_provider_key(provider_key: &str) -> &str { - match provider_key.trim() { - "claude-oauth" => "claude", - "anthropic-api-key" => "claude-api", - "openai-oauth" => "openai", - "openai-api-key" => "openai-api", - other => other, + // Fold any dual-auth (Anthropic/OpenAI OAuth-vs-API) alias onto its + // canonical session key via the single shared parser, so this never + // drifts from the route/runtime vocabularies. Non-dual keys pass through. + if let Some(route) = jcode_provider_core::AuthRoute::parse(provider_key) { + return route.session_provider_key(); } + provider_key.trim() } fn explicit_session_provider_key_for_model_request(model_request: &str) -> Option { @@ -225,13 +225,12 @@ impl MultiProvider { if let Some((prefix, rest)) = model_request.split_once(':') { let prefix = prefix.trim(); if !prefix.is_empty() && !rest.trim().is_empty() { + // Dual-auth (Anthropic/OpenAI) prefixes fold onto their canonical + // session key via the single shared parser. + if let Some(route) = jcode_provider_core::AuthRoute::parse(prefix) { + return Some(route.session_provider_key().to_string()); + } match prefix { - "claude-api" => return Some("claude-api".to_string()), - "claude-oauth" | "claude" | "anthropic" => { - return Some("claude".to_string()); - } - "openai-api" => return Some("openai-api".to_string()), - "openai-oauth" | "openai" => return Some("openai".to_string()), "copilot" | "antigravity" | "gemini" | "cursor" | "bedrock" | "openrouter" => { return Some(prefix.to_string()); } @@ -376,11 +375,13 @@ impl MultiProvider { // forked/child session that inherited it without `route_api_method`). let provider_key = Self::canonical_session_provider_key(provider_key); + // Dual-auth keys map to their canonical model prefix via the single + // shared parser, keeping the emitted prefix in lockstep with the parsers. + if let Some(route) = jcode_provider_core::AuthRoute::parse(provider_key) { + return format!("{}:{model}", route.model_prefix()); + } + match provider_key { - "claude-api" => format!("claude-api:{model}"), - "claude-oauth" | "claude" | "anthropic" => format!("claude-oauth:{model}"), - "openai-api" => format!("openai-api:{model}"), - "openai-oauth" | "openai" => format!("openai-oauth:{model}"), "copilot" | "antigravity" | "gemini" | "cursor" | "bedrock" | "openrouter" => { format!("{provider_key}:{model}") } diff --git a/crates/jcode-base/src/provider/tests/model_resolution.rs b/crates/jcode-base/src/provider/tests/model_resolution.rs index 478fc7b7c..86538702b 100644 --- a/crates/jcode-base/src/provider/tests/model_resolution.rs +++ b/crates/jcode-base/src/provider/tests/model_resolution.rs @@ -1073,8 +1073,10 @@ fn test_anthropic_auth_mode_prefixed_model_switch_changes_credentials() { assert_eq!( rt.block_on(anthropic.test_access_token_and_oauth_mode()) .expect("default token"), - ("sk-ant-test-api-key".to_string(), false), - "default Anthropic credentials should keep existing API-key-first behavior" + ("oauth-access-token".to_string(), true), + "default (Auto) Anthropic credentials prefer OAuth/subscription when an \ + OAuth account is available, matching the canonical OAuth-first Auto \ + behavior shared with the OpenAI provider and resolve_dual_credential_auth" ); provider diff --git a/crates/jcode-provider-core/src/auth_mode.rs b/crates/jcode-provider-core/src/auth_mode.rs new file mode 100644 index 000000000..511c3a655 --- /dev/null +++ b/crates/jcode-provider-core/src/auth_mode.rs @@ -0,0 +1,430 @@ +//! Canonical source of truth for the OAuth-vs-API-key credential decision of +//! the two "dual-auth" providers: Anthropic/Claude and OpenAI. +//! +//! These providers each support *both* a subscription/OAuth login and a direct +//! API key, so every request needs an explicit "which credential" decision. +//! +//! Historically jcode encoded that decision as free-form strings spread across +//! several overlapping vocabularies: +//! +//! | concept | runtime env (`JCODE_RUNTIME_PROVIDER`) | route / stable-id | CLI `--provider` | model prefix | +//! |----------------------|----------------------------------------|----------------------|------------------|------------------| +//! | Claude, OAuth | `claude` | `claude-oauth` | `claude` | `claude-oauth:` | +//! | Claude, API key | `claude-api` | `anthropic-api-key` | `anthropic-api` | `claude-api:` | +//! | OpenAI, OAuth | `openai` | `openai-oauth` | `openai` | `openai-oauth:` | +//! | OpenAI, API key | `openai-api` | `openai-api-key` | `openai-api` | `openai-api:` | +//! +//! Each call site used to parse its own subset of these aliases by hand, and the +//! subsets drifted: e.g. one parser accepted `openai` but not `openai-oauth`, +//! another accepted `claude`/`anthropic` but silently ignored `claude-oauth`. +//! When a string from one vocabulary leaked into a parser that only knew +//! another, the OAuth and API-key paths got mixed up. +//! +//! This module is the single place that: +//! * parses *any* alias from *any* vocabulary into a structured +//! [`AuthRoute`] (`provider` + `mode`), and +//! * emits the canonical string for each vocabulary. +//! +//! Every credential-mode parser and every UI/billing surface should go through +//! here instead of re-deriving the decision from ad-hoc string matches. + +use crate::ResolvedCredential; +use crate::selection::ActiveProvider; + +/// A provider that supports *both* a subscription/OAuth login and a direct +/// API-key credential, and therefore needs an explicit OAuth-vs-API decision. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DualAuthProvider { + /// Anthropic / Claude (Claude subscription OAuth vs `ANTHROPIC_API_KEY`). + Anthropic, + /// OpenAI (ChatGPT/Codex OAuth vs `OPENAI_API_KEY`). + OpenAI, +} + +impl DualAuthProvider { + /// The dual-auth provider backing an [`ActiveProvider`], if any. Returns + /// `None` for providers with no OAuth-vs-API-key ambiguity. + pub const fn from_active_provider(provider: ActiveProvider) -> Option { + match provider { + ActiveProvider::Claude => Some(Self::Anthropic), + ActiveProvider::OpenAI => Some(Self::OpenAI), + _ => None, + } + } + + /// The execution slot this credential decision routes through. + pub const fn active_provider(self) -> ActiveProvider { + match self { + Self::Anthropic => ActiveProvider::Claude, + Self::OpenAI => ActiveProvider::OpenAI, + } + } +} + +/// Which credential a dual-auth provider will actually use for a request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AuthMode { + /// OAuth / subscription login (Claude subscription, ChatGPT/Codex login). + Oauth, + /// Direct provider API key (metered / cost-based billing). + ApiKey, +} + +impl AuthMode { + /// True when requests bill against a subscription rather than a metered key. + pub const fn is_subscription(self) -> bool { + matches!(self, Self::Oauth) + } + + /// Map to the wire-level [`ResolvedCredential`] billing identity. + pub const fn resolved_credential(self) -> ResolvedCredential { + match self { + Self::Oauth => ResolvedCredential::Oauth, + Self::ApiKey => ResolvedCredential::ApiKey, + } + } +} + +impl From for ResolvedCredential { + fn from(mode: AuthMode) -> Self { + mode.resolved_credential() + } +} + +/// A fully resolved dual-auth credential decision: *which provider* and *which +/// credential*. This is the structured value that every vocabulary string maps +/// to and is generated from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AuthRoute { + pub provider: DualAuthProvider, + pub mode: AuthMode, +} + +impl AuthRoute { + pub const fn new(provider: DualAuthProvider, mode: AuthMode) -> Self { + Self { provider, mode } + } + + pub const fn anthropic(mode: AuthMode) -> Self { + Self::new(DualAuthProvider::Anthropic, mode) + } + + pub const fn openai(mode: AuthMode) -> Self { + Self::new(DualAuthProvider::OpenAI, mode) + } + + /// Parse a dual-auth token from *any* of jcode's overlapping vocabularies + /// (runtime env, route stable-id, CLI `--provider`, or bare model prefix). + /// + /// Returns `None` for tokens that do not pin a dual-auth credential route, + /// including bare aliases for non-dual providers (`openrouter`, `copilot`, + /// ...), unknown strings, and the empty string. A `None` result is what the + /// providers treat as "auto" (no explicit OAuth-vs-API pin). + /// + /// A single trailing `:` is tolerated so callers can pass a model prefix + /// such as `claude-oauth:` directly. Full prefixed model specs + /// (`claude-oauth:model`) are *not* parsed here; resolve the prefix with + /// `explicit_model_provider_prefix` first. + pub fn parse(token: &str) -> Option { + let token = token.trim().strip_suffix(':').unwrap_or(token.trim()); + match token.trim().to_ascii_lowercase().as_str() { + // Anthropic / Claude -- OAuth / subscription. + "claude" | "anthropic" | "claude-oauth" | "anthropic-oauth" => { + Some(Self::anthropic(AuthMode::Oauth)) + } + // Anthropic / Claude -- direct API key. + // + // Bare `api-key` historically resolves to Anthropic in the route + // vocabulary (see `ModelRouteApiMethod::parse`), so keep that. + "claude-api" | "anthropic-api" | "anthropic-api-key" | "claude-api-key" + | "anthropic-key" | "claude-key" | "api-key" => { + Some(Self::anthropic(AuthMode::ApiKey)) + } + // OpenAI -- OAuth / ChatGPT-Codex login. + "openai" | "openai-oauth" => Some(Self::openai(AuthMode::Oauth)), + // OpenAI -- direct API key. + "openai-api" | "openai-api-key" | "openai-key" | "openai-apikey" + | "openai-platform" | "platform-openai" => Some(Self::openai(AuthMode::ApiKey)), + _ => None, + } + } + + /// The execution slot this route runs through. + pub const fn active_provider(self) -> ActiveProvider { + self.provider.active_provider() + } + + /// The wire-level billing identity for this route. + pub const fn resolved_credential(self) -> ResolvedCredential { + self.mode.resolved_credential() + } + + /// Parse a *model prefix* that explicitly pins a dual-auth credential. + /// + /// This differs from [`AuthRoute::parse`] in the bare-provider cases: in the + /// model-prefix vocabulary `claude:` / `anthropic:` / `openai:` mean "route + /// to this provider but keep the current credential (auto)", so they do NOT + /// pin a credential and return `None` here. Only the explicit credential + /// prefixes (`claude-oauth:`, `claude-api:`, `openai-oauth:`, `openai-api:`, + /// and their stable-id spellings) pin one. + /// + /// A single trailing `:` is tolerated so callers can pass the raw prefix. + pub fn parse_explicit_credential_prefix(prefix: &str) -> Option { + let token = prefix.trim().strip_suffix(':').unwrap_or(prefix.trim()); + match token.trim().to_ascii_lowercase().as_str() { + // Bare provider aliases do not pin a credential in this vocabulary. + "claude" | "anthropic" | "openai" => None, + other => Self::parse(other), + } + } + + /// Canonical `JCODE_RUNTIME_PROVIDER` value that pins this route. + pub const fn runtime_provider_key(self) -> &'static str { + match (self.provider, self.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude", + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => "claude-api", + (DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai", + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api", + } + } + + /// Canonical route `api_method` / [`crate::RuntimeKey`] stable-id. + pub const fn route_api_method(self) -> &'static str { + match (self.provider, self.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude-oauth", + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => "anthropic-api-key", + (DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai-oauth", + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api-key", + } + } + + /// Canonical model-switch prefix (without the trailing colon). + pub const fn model_prefix(self) -> &'static str { + match (self.provider, self.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude-oauth", + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => "claude-api", + (DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai-oauth", + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api", + } + } + + /// Canonical session `provider_key` (the folded, route-free form). + pub const fn session_provider_key(self) -> &'static str { + // Identical to the runtime-env vocabulary today; kept as its own method + // so the session-key meaning is explicit at call sites. + self.runtime_provider_key() + } + + /// Canonical CLI `--provider` argument value. + pub const fn cli_provider_arg(self) -> &'static str { + match (self.provider, self.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude", + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => "anthropic-api", + (DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai", + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api", + } + } +} + +/// Resolve the explicit dual-auth mode that `runtime_provider` pins for a +/// specific provider. +/// +/// Returns `None` (i.e. "auto") when `runtime_provider` is absent, does not pin +/// a dual-auth route, or pins the *other* dual-auth provider. +pub fn pinned_mode_for( + provider: DualAuthProvider, + runtime_provider: Option<&str>, +) -> Option { + let route = AuthRoute::parse(runtime_provider?)?; + (route.provider == provider).then_some(route.mode) +} + +/// Read `JCODE_RUNTIME_PROVIDER` and return the dual-auth route it pins, if any. +pub fn runtime_env_auth_route() -> Option { + let value = std::env::var("JCODE_RUNTIME_PROVIDER").ok()?; + AuthRoute::parse(&value) +} + +/// Read `JCODE_RUNTIME_PROVIDER` and resolve the dual-auth mode it pins for a +/// specific provider (or `None` for "auto"). +pub fn runtime_env_pinned_mode(provider: DualAuthProvider) -> Option { + pinned_mode_for( + provider, + std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL_ROUTES: [AuthRoute; 4] = [ + AuthRoute::anthropic(AuthMode::Oauth), + AuthRoute::anthropic(AuthMode::ApiKey), + AuthRoute::openai(AuthMode::Oauth), + AuthRoute::openai(AuthMode::ApiKey), + ]; + + #[test] + fn every_vocabulary_string_round_trips_back_to_the_same_route() { + for route in ALL_ROUTES { + for token in [ + route.runtime_provider_key(), + route.route_api_method(), + route.model_prefix(), + route.cli_provider_arg(), + route.session_provider_key(), + ] { + assert_eq!( + AuthRoute::parse(token), + Some(route), + "token {token:?} should parse back to {route:?}", + ); + // Trailing-colon (model-prefix) form must parse identically. + assert_eq!( + AuthRoute::parse(&format!("{token}:")), + Some(route), + "token {token:?}: should parse back to {route:?}", + ); + } + } + } + + #[test] + fn parse_is_case_and_whitespace_insensitive() { + assert_eq!( + AuthRoute::parse(" Claude-OAuth "), + Some(AuthRoute::anthropic(AuthMode::Oauth)) + ); + assert_eq!( + AuthRoute::parse("ANTHROPIC-API-KEY"), + Some(AuthRoute::anthropic(AuthMode::ApiKey)) + ); + } + + #[test] + fn bare_provider_aliases_pin_oauth() { + assert_eq!( + AuthRoute::parse("claude").map(|r| r.mode), + Some(AuthMode::Oauth) + ); + assert_eq!( + AuthRoute::parse("anthropic").map(|r| r.mode), + Some(AuthMode::Oauth) + ); + assert_eq!( + AuthRoute::parse("openai").map(|r| r.mode), + Some(AuthMode::Oauth) + ); + } + + #[test] + fn cross_vocabulary_aliases_resolve_consistently() { + // The whole point: route-vocabulary strings and runtime-env strings for + // the same concept resolve to the same structured route. + for (a, b) in [ + ("claude", "claude-oauth"), + ("claude-api", "anthropic-api-key"), + ("openai", "openai-oauth"), + ("openai-api", "openai-api-key"), + ] { + assert_eq!( + AuthRoute::parse(a), + AuthRoute::parse(b), + "{a:?} and {b:?} must resolve to the same route", + ); + } + } + + #[test] + fn non_dual_and_unknown_tokens_are_none() { + for token in ["", "openrouter", "copilot", "gemini", "bedrock", "jcode", "nonsense"] { + assert_eq!(AuthRoute::parse(token), None, "{token:?} must be None"); + } + } + + #[test] + fn explicit_credential_prefix_ignores_bare_provider_aliases() { + // Bare provider prefixes route without pinning a credential. + for token in ["claude", "claude:", "anthropic:", "openai", "openai:"] { + assert_eq!( + AuthRoute::parse_explicit_credential_prefix(token), + None, + "{token:?} must not pin a credential", + ); + } + // Explicit credential prefixes still pin. + assert_eq!( + AuthRoute::parse_explicit_credential_prefix("claude-oauth:"), + Some(AuthRoute::anthropic(AuthMode::Oauth)) + ); + assert_eq!( + AuthRoute::parse_explicit_credential_prefix("claude-api:"), + Some(AuthRoute::anthropic(AuthMode::ApiKey)) + ); + assert_eq!( + AuthRoute::parse_explicit_credential_prefix("openai-oauth:"), + Some(AuthRoute::openai(AuthMode::Oauth)) + ); + assert_eq!( + AuthRoute::parse_explicit_credential_prefix("openai-api:"), + Some(AuthRoute::openai(AuthMode::ApiKey)) + ); + } + + #[test] + fn pinned_mode_only_matches_its_own_provider() { + assert_eq!( + pinned_mode_for(DualAuthProvider::Anthropic, Some("claude-api")), + Some(AuthMode::ApiKey) + ); + // A pin for the *other* dual-auth provider is "auto" here. + assert_eq!( + pinned_mode_for(DualAuthProvider::Anthropic, Some("openai")), + None + ); + assert_eq!(pinned_mode_for(DualAuthProvider::OpenAI, Some("claude")), None); + assert_eq!(pinned_mode_for(DualAuthProvider::OpenAI, None), None); + } + + #[test] + fn resolved_credential_mapping() { + assert_eq!( + AuthMode::Oauth.resolved_credential(), + ResolvedCredential::Oauth + ); + assert_eq!( + AuthMode::ApiKey.resolved_credential(), + ResolvedCredential::ApiKey + ); + } + + #[test] + fn route_api_method_round_trips_through_model_route_api_method() { + use crate::ModelRouteApiMethod; + // The route-vocabulary parser (`ModelRouteApiMethod`) must agree with the + // canonical auth-mode parser for every dual-auth route, so routing and + // billing never disagree about OAuth-vs-API-key. + for route in ALL_ROUTES { + let parsed = ModelRouteApiMethod::parse(route.route_api_method()); + assert_eq!( + parsed, + ModelRouteApiMethod::from_auth_route(route), + "route {route:?} api_method must round-trip through ModelRouteApiMethod", + ); + // And every alias vocabulary maps to the same ModelRouteApiMethod. + for token in [ + route.runtime_provider_key(), + route.model_prefix(), + route.cli_provider_arg(), + route.session_provider_key(), + ] { + assert_eq!( + ModelRouteApiMethod::parse(token), + parsed, + "token {token:?} must map to the same ModelRouteApiMethod as {route:?}", + ); + } + } + } +} diff --git a/crates/jcode-provider-core/src/lib.rs b/crates/jcode-provider-core/src/lib.rs index da5bb929d..a0d59decb 100644 --- a/crates/jcode-provider-core/src/lib.rs +++ b/crates/jcode-provider-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod anthropic; +pub mod auth_mode; pub mod catalog_refresh; pub mod failover; pub mod models; @@ -13,6 +14,10 @@ pub use anthropic::{ anthropic_oauth_beta_headers, anthropic_stainless_arch, anthropic_stainless_os, anthropic_strip_1m_suffix, }; +pub use auth_mode::{ + AuthMode, AuthRoute, DualAuthProvider, pinned_mode_for, runtime_env_auth_route, + runtime_env_pinned_mode, +}; pub use catalog_refresh::{ModelCatalogRefreshSummary, summarize_model_catalog_refresh}; pub use failover::{ FailoverDecision, ProviderFailoverPrompt, classify_failover_error_message, @@ -611,14 +616,27 @@ pub enum ModelRouteApiMethod { } impl ModelRouteApiMethod { + /// The route-vocabulary api_method for a canonical dual-auth route. + pub fn from_auth_route(route: crate::auth_mode::AuthRoute) -> Self { + use crate::auth_mode::{AuthMode, DualAuthProvider}; + match (route.provider, route.mode) { + (DualAuthProvider::Anthropic, AuthMode::Oauth) => Self::ClaudeOAuth, + (DualAuthProvider::Anthropic, AuthMode::ApiKey) => Self::AnthropicApiKey, + (DualAuthProvider::OpenAI, AuthMode::Oauth) => Self::OpenAIOAuth, + (DualAuthProvider::OpenAI, AuthMode::ApiKey) => Self::OpenAIApiKey, + } + } + pub fn parse(value: &str) -> Self { let trimmed = value.trim(); let lower = trimmed.to_ascii_lowercase(); + // Dual-auth (Anthropic/OpenAI OAuth-vs-API) tokens share one canonical + // alias table so the route vocabulary never drifts from the runtime/CLI + // vocabularies. Anything else falls through to the route-only methods. + if let Some(route) = crate::auth_mode::AuthRoute::parse(&lower) { + return Self::from_auth_route(route); + } match lower.as_str() { - "claude" | "claude-oauth" => Self::ClaudeOAuth, - "api-key" | "claude-api" | "anthropic-api-key" => Self::AnthropicApiKey, - "openai" | "openai-oauth" => Self::OpenAIOAuth, - "openai-api" | "openai-api-key" => Self::OpenAIApiKey, "openrouter" => Self::OpenRouter, "openai-compatible" => Self::OpenAiCompatible { profile_id: None }, "copilot" => Self::Copilot, diff --git a/crates/jcode-provider-core/src/selection.rs b/crates/jcode-provider-core/src/selection.rs index 1c4139cba..7689f25da 100644 --- a/crates/jcode-provider-core/src/selection.rs +++ b/crates/jcode-provider-core/src/selection.rs @@ -141,11 +141,12 @@ pub fn cli_provider_arg_for_session_key(key: &str) -> Option<&'static str> { .split_once(':') .map(|(prefix, _rest)| prefix) .unwrap_or(normalized.as_str()); + // Dual-auth (Anthropic/OpenAI OAuth-vs-API) keys share one canonical alias + // table, so the CLI arg never drifts from the route/runtime vocabularies. + if let Some(route) = crate::auth_mode::AuthRoute::parse(base) { + return Some(route.cli_provider_arg()); + } match base { - "claude" | "claude-oauth" | "anthropic" => Some("claude"), - "anthropic-api-key" | "claude-api" | "api-key" | "anthropic-api" => Some("anthropic-api"), - "openai" | "openai-oauth" => Some("openai"), - "openai-api-key" | "openai-api" => Some("openai-api"), "openrouter" => Some("openrouter"), "copilot" => Some("copilot"), "gemini" => Some("gemini"), diff --git a/crates/jcode-tui/src/tui/app/misc_ui.rs b/crates/jcode-tui/src/tui/app/misc_ui.rs index fe8d33ae9..b4ec9c688 100644 --- a/crates/jcode-tui/src/tui/app/misc_ui.rs +++ b/crates/jcode-tui/src/tui/app/misc_ui.rs @@ -160,14 +160,22 @@ impl App { let runtime_provider = active_runtime_provider_key(); let auth_status = crate::auth::AuthStatus::check_fast(); - let is_explicit_anthropic_api = matches!( + let pinned_anthropic = jcode_provider_core::pinned_mode_for( + jcode_provider_core::DualAuthProvider::Anthropic, runtime_provider.as_deref(), - Some("claude-api" | "anthropic-api") ); + let pinned_openai = jcode_provider_core::pinned_mode_for( + jcode_provider_core::DualAuthProvider::OpenAI, + runtime_provider.as_deref(), + ); + let is_explicit_anthropic_api = + matches!(pinned_anthropic, Some(jcode_provider_core::AuthMode::ApiKey)); let is_explicit_anthropic_oauth = - matches!(runtime_provider.as_deref(), Some("claude" | "anthropic")); - let is_explicit_openai_api = matches!(runtime_provider.as_deref(), Some("openai-api")); - let is_explicit_openai_oauth = matches!(runtime_provider.as_deref(), Some("openai")); + matches!(pinned_anthropic, Some(jcode_provider_core::AuthMode::Oauth)); + let is_explicit_openai_api = + matches!(pinned_openai, Some(jcode_provider_core::AuthMode::ApiKey)); + let is_explicit_openai_oauth = + matches!(pinned_openai, Some(jcode_provider_core::AuthMode::Oauth)); let is_anthropic = provider_name.contains("anthropic") || provider_name.contains("claude"); let is_openai = provider_name.contains("openai"); From 058e5a80861846e093167ef6161c478962b1163a Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:01:14 -0700 Subject: [PATCH 05/41] fix(reasoning): make 'current' mode ephemeral and in-place In 'current' reasoning-display mode each closed reasoning block was moved into a dedicated persistent "reasoning" display message that collapsed to a '> thought' trace and never went away. This caused three visible bugs: - Reasoning accumulated: every block left a stacked trace line, so the transcript showed all past reasoning instead of just the current one. - Interleaved answer/reasoning/answer was reordered: extracting the block mid-stream floated reasoning above still-uncommitted answer text and merged the pre- and post-reasoning paragraphs. - Live vs reloaded rendering diverged (separate block + 'for Ns' summary live; inline assistant text + '(N lines)' summary on reload). Now 'current' reasoning is ephemeral: it streams live as dim+italic text, then is sliced straight back out of the stream in place once the model starts answering or runs a tool. Only the live block is ever shown, answer order is preserved, and nothing accumulates. Reloaded history hides past reasoning to match. Removes the ReasoningCollapse animation machinery. Tests updated to cover ordering preservation and non-accumulation. --- crates/jcode-base/src/session/render.rs | 18 +- crates/jcode-base/src/session_tests/cases.rs | 19 +- crates/jcode-tui/src/tui/app.rs | 33 +-- crates/jcode-tui/src/tui/app/input.rs | 208 ++-------------- crates/jcode-tui/src/tui/app/local.rs | 4 - crates/jcode-tui/src/tui/app/remote.rs | 1 - .../src/tui/app/state_ui_messages.rs | 9 +- .../src/tui/app/tests/reasoning_region.rs | 226 ++++++------------ crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 4 - crates/jcode-tui/src/tui/app/tui_state.rs | 4 - crates/jcode-tui/src/tui/app/turn.rs | 2 - crates/jcode-tui/src/tui/mod.rs | 7 - 12 files changed, 116 insertions(+), 419 deletions(-) diff --git a/crates/jcode-base/src/session/render.rs b/crates/jcode-base/src/session/render.rs index 4683407eb..516d53fcc 100644 --- a/crates/jcode-base/src/session/render.rs +++ b/crates/jcode-base/src/session/render.rs @@ -21,9 +21,9 @@ pub const DEFAULT_VISIBLE_COMPACTED_HISTORY_MESSAGES: usize = 64; /// Honors the active `reasoning_display` mode so re-rendered history (reload, /// resume, remote sync, compaction-window expand) matches the live behavior: /// - `Off`: persisted reasoning is hidden entirely. -/// - `Current`: the block folds down to a single `▸ thought (N lines)` trace, -/// matching the live collapse animation's end state rather than replaying the -/// full reasoning back into the transcript on every reload. +/// - `Current`: only the *live* reasoning block is ever shown, so historical +/// reasoning is hidden on re-render (the live block already streamed and was +/// discarded once the model answered), matching the ephemeral live behavior. /// - `Full`: every reasoning line is shown (classic behavior). fn format_reasoning_markup(text: &str) -> String { if text.trim().is_empty() { @@ -31,14 +31,10 @@ fn format_reasoning_markup(text: &str) -> String { } let mode = crate::config::config().display.reasoning_display(); match mode { - ReasoningDisplayMode::Off => return String::new(), - ReasoningDisplayMode::Current => { - let line_count = text.lines().filter(|l| !l.trim().is_empty()).count(); - let mut out = jcode_render_core::reasoning_summary_line_markup(line_count); - // Blank line terminates the reasoning block. - out.push('\n'); - return out; - } + // In both `Off` and `Current` modes persisted reasoning is not re-rendered: + // `Current` only ever shows the live block, which is discarded once the + // model answers, so reloaded history shows no past reasoning. + ReasoningDisplayMode::Off | ReasoningDisplayMode::Current => return String::new(), ReasoningDisplayMode::Full => {} } let mut out = String::new(); diff --git a/crates/jcode-base/src/session_tests/cases.rs b/crates/jcode-base/src/session_tests/cases.rs index 858cc5010..31e23cdd6 100644 --- a/crates/jcode-base/src/session_tests/cases.rs +++ b/crates/jcode-base/src/session_tests/cases.rs @@ -1139,7 +1139,7 @@ fn test_render_messages_renders_legacy_reasoning_variant() { } #[test] -fn test_render_messages_collapses_persisted_reasoning_in_current_mode() { +fn test_render_messages_hides_persisted_reasoning_in_current_mode() { use jcode_render_core::REASONING_SENTINEL; let _env_lock = lock_env(); @@ -1168,17 +1168,20 @@ fn test_render_messages_collapses_persisted_reasoning_in_current_mode() { let rendered = render_messages(&session); assert_eq!(rendered.len(), 1); let content = &rendered[0].content; - // In `current` mode re-rendered history folds the whole reasoning block down - // to a single dim/italic trace line, matching the live collapse end state. + // In `current` mode only the *live* reasoning block is ever shown; it streams + // then is discarded once the model answers. Re-rendered history therefore + // shows no past reasoning at all (no trace line, no lines, no sentinel). assert!( - content.contains(&format!("*{0}▸ thought (3 lines){0}*", REASONING_SENTINEL)), - "expected collapsed reasoning summary, got: {content:?}" + !content.contains(REASONING_SENTINEL), + "no reasoning markup expected in current mode on reload: {content:?}" ); assert!( - !content.contains("step one") && !content.contains("step two"), - "individual reasoning lines must not be replayed in current mode: {content:?}" + !content.contains("step one") + && !content.contains("step two") + && !content.contains("thought"), + "individual reasoning lines/trace must not be replayed in current mode: {content:?}" ); - // The answer text is preserved and follows the collapsed trace. + // The answer text is preserved. assert!(content.contains("Here is the answer.")); } diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 447d02ecf..4dc8e889e 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -340,28 +340,6 @@ pub enum ProcessingStatus { RunningTool(String), } -/// Live "collapse the current reasoning" animation state. -/// -/// In `current` reasoning-display mode the model's reasoning streams live as -/// dim+italic lines, then must disappear once the answer commits or a tool runs. -/// Instead of deleting every reasoning line in a single frame (a jarring upward -/// jump), the closed reasoning block is moved into a dedicated `"reasoning"` -/// display message that height-collapses toward a one-line summary over a short -/// ease-out, leaving a `▸ thought for Xs` trace behind. -#[derive(Clone, Debug)] -pub(crate) struct ReasoningCollapse { - /// Index into `display_messages` of the `"reasoning"` message being collapsed. - pub(crate) msg_index: usize, - /// One-line dim summary the block collapses down to (markup for - /// "▸ thought for Xs"), always shown at the top of the message. - pub(crate) summary_markup: String, - /// Per-line dim+italic markup for each reasoning line, in order. The block - /// shrinks by dropping leading lines until only `summary_markup` remains. - pub(crate) line_markups: Vec, - /// When the collapse animation started. - pub(crate) started_at: Instant, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum RemoteStartupPhase { StartingServer, @@ -736,16 +714,9 @@ pub struct App { reasoning_partial_len: usize, // Byte offset in `streaming_text` where the current reasoning block began // (recorded by `open_reasoning_region`). Used in `current` mode to slice the - // closed reasoning block out of the stream and hand it to the collapse - // animation while keeping any answer text that preceded it in order. + // closed reasoning block back out of the stream in place, keeping any answer + // text that preceded it in order. reasoning_block_start: Option, - // Wall-clock instant the current reasoning region opened, used to label the - // collapsed summary ("▸ thought for Xs"). - reasoning_block_started_at: Option, - // Active "collapse the current reasoning" animation (current mode only). While - // set, a `"reasoning"` display message height-collapses toward its one-line - // summary; the redraw loop advances it each frame and finalizes on completion. - reasoning_collapse: Option, // Hot-reload: if set, exec into new binary with this session ID (no rebuild) reload_requested: Option, // Hot-rebuild: if set, do full git pull + cargo build + tests then exec diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 6fe5b2bd9..cee4e3f14 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -50,47 +50,6 @@ pub(super) fn strip_reasoning_lines(content: &str) -> String { result.trim_end().to_string() } -/// Total duration of the "current reasoning collapses away" height animation. -pub(super) const REASONING_COLLAPSE_DURATION: Duration = Duration::from_millis(280); - -/// Split a just-closed reasoning block (sentinel-wrapped dim/italic line markup, -/// as produced by [`jcode_tui_markdown::reasoning_line_markup`]) into one markup -/// string per visible reasoning line. Blank separator lines are dropped so the -/// collapse animates over real thought lines only. -pub(super) fn reasoning_block_line_markups(block: &str) -> Vec { - block - .split_inclusive('\n') - .filter(|segment| segment.contains(jcode_tui_markdown::REASONING_SENTINEL)) - .map(|segment| segment.to_string()) - .collect() -} - -/// One-line dim summary the collapsed reasoning folds into. Includes a `▸` marker -/// and the thinking duration when known (e.g. `▸ thought for 12s`). -pub(super) fn reasoning_summary_markup(line_count: usize, elapsed: Option) -> String { - let label = match elapsed { - Some(d) if d.as_secs() >= 1 => format!("▸ thought for {}s", d.as_secs()), - Some(_) => "▸ thought".to_string(), - None if line_count == 1 => "▸ thought (1 line)".to_string(), - None => format!("▸ thought ({} lines)", line_count), - }; - jcode_tui_markdown::reasoning_line_markup(&label) -} - -/// Build the transcript content for a collapsing `"reasoning"` message: the last -/// `remaining` reasoning lines, or just the summary line once fully collapsed. -pub(super) fn reasoning_message_content( - summary_markup: &str, - line_markups: &[String], - remaining: usize, -) -> String { - if remaining == 0 || line_markups.is_empty() { - return summary_markup.to_string(); - } - let remaining = remaining.min(line_markups.len()); - let start = line_markups.len() - remaining; - line_markups[start..].concat() -} pub(super) fn edit_input_in_external_editor(app: &mut App) { match edit_text_in_external_editor(&app.input) { @@ -2443,10 +2402,9 @@ impl App { self.reasoning_pending_line.clear(); self.reasoning_partial_len = 0; // Remember where this reasoning block starts in the stream so `current` - // mode can later slice it out (without disturbing any preceding answer - // text) and hand it to the collapse animation. + // mode can later slice it back out in place (without disturbing any + // preceding answer text) once the model starts answering. self.reasoning_block_start = Some(self.streaming_text.len()); - self.reasoning_block_started_at = Some(Instant::now()); } /// Remove the live partial-reasoning tail (the rendered, not-yet-committed @@ -2513,13 +2471,16 @@ impl App { } self.reasoning_streaming = false; - // In `current` mode, animate the block away instead of leaving it in the - // stream to be stripped wholesale at commit time. + // In `current` mode, reasoning is ephemeral: only the *current* (live) + // block is ever shown. Once it closes (the model starts answering or runs + // a tool), slice it straight back out of the stream in place. This keeps + // any answer text that preceded it in order and never accumulates a + // separate trace message for past reasoning. if matches!( crate::config::config().display.reasoning_display(), crate::config::ReasoningDisplayMode::Current ) { - self.begin_reasoning_collapse(); + self.discard_current_reasoning_block(); return; } @@ -2535,145 +2496,25 @@ impl App { self.refresh_split_view_if_needed(); } - /// Slice the just-closed reasoning block out of `streaming_text` and move it - /// into a dedicated `"reasoning"` display message, then start (or replace) the - /// height-collapse animation. Any answer text streamed *before* the reasoning - /// block is left untouched so ordering is preserved. With decorative - /// animations disabled (reduced motion / low-power tiers) the block is - /// finalized straight to its summary line. - pub(super) fn begin_reasoning_collapse(&mut self) { - let block_start = self.reasoning_block_start.take().unwrap_or(0); - let started_at = self.reasoning_block_started_at.take(); - // Finalize any previous collapse first so its message snaps to its summary - // instead of being orphaned mid-animation. - self.finalize_reasoning_collapse(); - - let block_start = block_start.min(self.streaming_text.len()); - + /// Slice the just-closed reasoning block out of `streaming_text` in place, + /// leaving any answer text that streamed *before* it untouched and in order. + /// Used in `current` mode so only the live reasoning block is ever visible and + /// no per-block trace is left behind. + pub(super) fn discard_current_reasoning_block(&mut self) { + let block_start = self + .reasoning_block_start + .take() + .unwrap_or(0) + .min(self.streaming_text.len()); // Everything from the block start onward is reasoning markup (plus the - // separators inserted by open/close). Take it out of the live stream. - let block: String = self.streaming_text.split_off(block_start); - // Drop a trailing separator the answer-side path would otherwise add. + // separators inserted by open/close). Drop it from the live stream. + self.streaming_text.truncate(block_start); + // Drop the separator the open path added before the reasoning block so the + // surrounding answer text rejoins cleanly. while self.streaming_text.ends_with('\n') { self.streaming_text.pop(); } self.refresh_split_view_if_needed(); - - let line_markups = reasoning_block_line_markups(&block); - if line_markups.is_empty() { - // Nothing to show (e.g. empty reasoning); just clear state. - self.reasoning_collapse = None; - return; - } - - let elapsed = started_at.map(|t| t.elapsed()); - let summary_markup = reasoning_summary_markup(line_markups.len(), elapsed); - - // Build the committed message content: every reasoning line, then the - // summary as the final line. The renderer reveals a shrinking suffix. - let content = - reasoning_message_content(&summary_markup, &line_markups, line_markups.len()); - - let msg_index = self.display_messages.len(); - self.push_display_message(DisplayMessage::reasoning(content)); - - let decorative = crate::perf::tui_policy().enable_decorative_animations; - if !decorative { - // Reduced motion: snap straight to the one-line summary. - self.replace_display_message_content( - msg_index, - reasoning_message_content(&summary_markup, &line_markups, 0), - ); - self.reasoning_collapse = None; - return; - } - - self.reasoning_collapse = Some(super::ReasoningCollapse { - msg_index, - summary_markup, - line_markups, - started_at: Instant::now(), - }); - } - - /// Advance the active reasoning-collapse animation. Returns `true` when the - /// transcript changed (so the caller should request a redraw). Finalizes to - /// the summary line once the animation completes. - pub(super) fn advance_reasoning_collapse(&mut self) -> bool { - let Some(collapse) = self.reasoning_collapse.as_ref() else { - return false; - }; - - // If the target message moved or was replaced (compaction/rewind), drop the - // animation rather than risk mutating an unrelated message. - if self - .display_messages - .get(collapse.msg_index) - .map(|m| m.role.as_str()) - != Some("reasoning") - { - self.reasoning_collapse = None; - return false; - } - - let total = collapse.line_markups.len(); - let elapsed = collapse.started_at.elapsed(); - let progress = - (elapsed.as_secs_f32() / REASONING_COLLAPSE_DURATION.as_secs_f32()).clamp(0.0, 1.0); - // Ease-out cubic so the block decelerates as it folds away. - let eased = 1.0 - (1.0 - progress).powi(3); - // Number of reasoning lines still visible above the summary. Counts down - // from `total` to 0 (only the summary remains). - let remaining = ((total as f32) * (1.0 - eased)).round() as usize; - let remaining = remaining.min(total); - - let msg_index = collapse.msg_index; - let content = - reasoning_message_content(&collapse.summary_markup, &collapse.line_markups, remaining); - let changed = self.replace_display_message_content(msg_index, content); - - if progress >= 1.0 { - self.reasoning_collapse = None; - } - changed - } - - /// Whether a reasoning-collapse animation is currently running. - pub(super) fn reasoning_collapse_active(&self) -> bool { - self.reasoning_collapse.is_some() - } - - /// Test hook: backdate the active collapse's start so `advance_*` observes a - /// specific elapsed fraction, and return the number of source reasoning lines. - #[cfg(test)] - pub(super) fn backdate_reasoning_collapse_for_test( - &mut self, - elapsed: std::time::Duration, - ) -> Option { - let collapse = self.reasoning_collapse.as_mut()?; - collapse.started_at = Instant::now() - .checked_sub(elapsed) - .unwrap_or_else(Instant::now); - Some(collapse.line_markups.len()) - } - - /// Finalize any in-flight reasoning collapse immediately (snap to summary). - /// Used when the turn ends or state is reset so no animation is left dangling. - pub(super) fn finalize_reasoning_collapse(&mut self) { - if let Some(collapse) = self.reasoning_collapse.take() { - if self - .display_messages - .get(collapse.msg_index) - .map(|m| m.role.as_str()) - == Some("reasoning") - { - let content = - reasoning_message_content(&collapse.summary_markup, &collapse.line_markups, 0); - self.replace_display_message_content(collapse.msg_index, content); - } - } - self.reasoning_block_start = None; - self.reasoning_block_started_at = None; } pub(super) fn append_streaming_text(&mut self, text: &str) { @@ -2709,10 +2550,8 @@ impl App { self.reasoning_streaming = false; self.reasoning_pending_line.clear(); self.reasoning_partial_len = 0; - // The stream (and any block offset into it) is gone; a running collapse - // targets a separate display message and is left to finish on its own. + // The stream (and any block offset into it) is gone. self.reasoning_block_start = None; - self.reasoning_block_started_at = None; self.refresh_split_view_if_needed(); self.streaming_md_renderer.borrow_mut().reset(); crate::tui::mermaid::clear_streaming_preview_diagram(); @@ -2725,7 +2564,6 @@ impl App { self.reasoning_pending_line.clear(); self.reasoning_partial_len = 0; self.reasoning_block_start = None; - self.reasoning_block_started_at = None; self.refresh_split_view_if_needed(); self.streaming_md_renderer.borrow_mut().reset(); crate::tui::mermaid::clear_streaming_preview_diagram(); diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index 204730a75..b98883f7a 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -55,7 +55,6 @@ pub(super) async fn process_turn_with_input( pub(super) fn handle_tick(app: &mut App) -> bool { let mut needs_redraw = crate::tui::periodic_redraw_required(app); - needs_redraw |= app.advance_reasoning_collapse(); app.maybe_capture_runtime_memory_heartbeat(); needs_redraw |= app.progress_copy_selection_edge_autoscroll(); app.progress_mouse_scroll_animation(); @@ -473,9 +472,6 @@ pub(super) fn finish_turn(app: &mut App) { app.thought_line_inserted = false; app.thinking_prefix_emitted = false; app.thinking_buffer.clear(); - // Snap any in-flight reasoning collapse straight to its summary so no - // animation is left running once the turn is idle. - app.finalize_reasoning_collapse(); app.note_runtime_memory_event_force("turn_completed", "local_turn_finished"); if !app.schedule_auto_poke_followup_if_needed() && !app.schedule_overnight_poke_followup_if_needed() diff --git a/crates/jcode-tui/src/tui/app/remote.rs b/crates/jcode-tui/src/tui/app/remote.rs index 4935da620..db3d3f8ab 100644 --- a/crates/jcode-tui/src/tui/app/remote.rs +++ b/crates/jcode-tui/src/tui/app/remote.rs @@ -75,7 +75,6 @@ pub(super) async fn handle_tick(app: &mut App, remote: &mut RemoteConnection) -> .is_some_and(|state| state.kind == crate::tui::PickerKind::Model), }); let mut needs_redraw = crate::tui::periodic_redraw_required(app); - needs_redraw |= app.advance_reasoning_collapse(); app.maybe_capture_runtime_memory_heartbeat(); needs_redraw |= app.progress_copy_selection_edge_autoscroll(); app.progress_mouse_scroll_animation(); diff --git a/crates/jcode-tui/src/tui/app/state_ui_messages.rs b/crates/jcode-tui/src/tui/app/state_ui_messages.rs index 906e4cc68..5e2a58dfd 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_messages.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_messages.rs @@ -79,8 +79,6 @@ impl App { pub(super) fn replace_display_messages(&mut self, mut messages: Vec) { compact_display_messages_for_storage(&mut messages); - // Indices the collapse animation targets no longer apply to the new list. - self.reasoning_collapse = None; self.display_messages = messages; self.sync_compacted_history_lazy_from_display_messages(); self.bump_display_messages_version(); @@ -343,12 +341,9 @@ impl App { pub(super) fn clear_display_messages(&mut self) { self.compacted_history_lazy = CompactedHistoryLazyState::default(); - // The transcript (and the index the collapse animation targets) is about - // to be discarded; drop any in-flight collapse so it can't mutate a stale - // or unrelated message. - self.reasoning_collapse = None; + // The transcript is about to be discarded; forget where the live reasoning + // block started so a stale offset can't slice the new stream. self.reasoning_block_start = None; - self.reasoning_block_started_at = None; if !self.display_messages.is_empty() { self.display_messages.clear(); self.bump_display_messages_version(); diff --git a/crates/jcode-tui/src/tui/app/tests/reasoning_region.rs b/crates/jcode-tui/src/tui/app/tests/reasoning_region.rs index b4d1ecf76..5d10f697b 100644 --- a/crates/jcode-tui/src/tui/app/tests/reasoning_region.rs +++ b/crates/jcode-tui/src/tui/app/tests/reasoning_region.rs @@ -10,6 +10,11 @@ // The in-progress (not yet newline-terminated) line renders live as a partial // `*…*` tail so reasoning trickles in token-by-token; that tail is rebuilt in // place on each delta and promoted to a committed line when its newline arrives. +// +// In `current` mode (the default) reasoning is *ephemeral*: only the live block is +// ever shown. Once it closes (the model answers or runs a tool) the whole block is +// sliced back out of the stream in place, so no per-block trace accumulates and +// answer text keeps its order. #[test] fn reasoning_region_emits_dim_italic_lines_no_gutter_header_or_footer() { @@ -41,23 +46,17 @@ fn reasoning_region_emits_dim_italic_lines_no_gutter_header_or_footer() { "second line not dim+italic: {streaming:?}" ); - // In `current` mode (the default), closing moves the block into a dedicated - // collapsing `"reasoning"` display message and clears it from the stream. + // In `current` mode (the default), closing discards the block in place: it + // leaves the live stream entirely and never becomes a persistent message. app.close_reasoning_region(None); assert!( app.streaming_text().is_empty(), - "reasoning should leave the live stream once collapsed: {:?}", + "reasoning should leave the live stream once discarded: {:?}", app.streaming_text() ); - let reasoning_msg = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .expect("reasoning message present"); assert!( - reasoning_msg.content.contains(sentinel), - "reasoning message keeps dim+italic markup: {:?}", - reasoning_msg.content + !app.display_messages.iter().any(|m| m.role == "reasoning"), + "ephemeral reasoning must not create a persistent message" ); } @@ -85,14 +84,15 @@ fn reasoning_region_closes_before_normal_output() { !answer_line.contains(jcode_tui_markdown::REASONING_SENTINEL), "final answer must not be styled as reasoning: {answer_line:?}" ); - // The reasoning collapsed into its own message; it is no longer in the stream. + // The reasoning was discarded; it is no longer in the stream and no persistent + // reasoning message was created. assert!( !text.contains(jcode_tui_markdown::REASONING_SENTINEL), "reasoning must not remain in the answer stream: {text:?}" ); assert!( - app.display_messages.iter().any(|m| m.role == "reasoning"), - "a collapsing reasoning message should exist" + !app.display_messages.iter().any(|m| m.role == "reasoning"), + "ephemeral reasoning must not create a persistent message" ); } @@ -129,16 +129,9 @@ fn reasoning_line_split_across_deltas_stays_one_run() { app.open_reasoning_region(); app.append_reasoning_text("one "); app.append_reasoning_text("two\n"); - app.close_reasoning_region(None); - // The split-across-deltas line is committed as a single emphasis run in the - // collapsed reasoning message. - let content = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| m.content.clone()) - .expect("reasoning message present"); + // While streaming live, the split-across-deltas line is a single emphasis run. + let content = app.streaming_text(); let sentinel = jcode_tui_markdown::REASONING_SENTINEL; assert!( content.contains(&format!("*{sentinel}one two{sentinel}*")), @@ -154,15 +147,9 @@ fn reasoning_region_renders_dim_italic_text_without_gutter() { app.open_reasoning_region(); app.append_reasoning_text("considering options\n"); - app.close_reasoning_region(None); - // In `current` mode the reasoning now lives in a dedicated collapsing message. - let reasoning_content = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| m.content.clone()) - .expect("reasoning message present"); + // The live reasoning renders dim+italic from the streaming buffer. + let reasoning_content = app.streaming_text().to_string(); let lines = crate::tui::markdown::render_markdown_with_width(&reasoning_content, Some(80)); let body = lines @@ -308,152 +295,81 @@ fn reasoning_close_promotes_pending_partial_line() { app.append_reasoning_text("final thought"); app.close_reasoning_region(None); - // The live stream no longer carries the reasoning; it moved into its message. + // The reasoning is discarded in place on close: it leaves the live stream and + // never becomes a persistent message. + let _ = sentinel; assert!( app.streaming_text().is_empty(), - "reasoning should leave the live stream once collapsed: {:?}", + "reasoning should leave the live stream once discarded: {:?}", app.streaming_text() ); - let content = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| m.content.clone()) - .expect("reasoning message present"); - assert_eq!( - content - .matches(&format!("*{sentinel}final thought{sentinel}*")) - .count(), - 1, - "pending partial promoted exactly once on close: {content:?}" + assert!( + !app.display_messages.iter().any(|m| m.role == "reasoning"), + "ephemeral reasoning must not create a persistent message" ); } #[test] -fn reasoning_block_line_markups_keeps_only_sentinel_lines() { - use crate::tui::app::input::{reasoning_block_line_markups, reasoning_message_content}; - - let mut block = String::new(); - block.push_str(&jcode_tui_markdown::reasoning_line_markup("alpha")); - block.push('\n'); // a blank separator line (no sentinel) - block.push_str(&jcode_tui_markdown::reasoning_line_markup("beta")); - - let lines = reasoning_block_line_markups(&block); - assert_eq!(lines.len(), 2, "blank separators are dropped: {lines:?}"); - let sentinel = jcode_tui_markdown::REASONING_SENTINEL; - assert!(lines[0].contains(&format!("{sentinel}alpha{sentinel}"))); - assert!(lines[1].contains(&format!("{sentinel}beta{sentinel}"))); - - // Full content shows every line; remaining==0 shows only the summary. - let summary = jcode_tui_markdown::reasoning_line_markup("▸ thought"); - let full = reasoning_message_content(&summary, &lines, lines.len()); - assert!(full.contains("alpha") && full.contains("beta")); - let collapsed = reasoning_message_content(&summary, &lines, 0); - assert!(collapsed.contains("▸ thought")); - assert!(!collapsed.contains("alpha") && !collapsed.contains("beta")); - - // A partial reveal keeps the *trailing* lines (oldest fold away first). - let partial = reasoning_message_content(&summary, &lines, 1); - assert!(partial.contains("beta"), "trailing line kept: {partial:?}"); - assert!(!partial.contains("alpha"), "leading line folded: {partial:?}"); -} - -#[test] -fn reasoning_summary_markup_uses_duration_when_known() { - use crate::tui::app::input::reasoning_summary_markup; - use std::time::Duration; - - let with_secs = reasoning_summary_markup(3, Some(Duration::from_secs(12))); - assert!(with_secs.contains("▸ thought for 12s"), "{with_secs:?}"); - - let no_time = reasoning_summary_markup(4, None); - assert!(no_time.contains("▸ thought (4 lines)"), "{no_time:?}"); -} - -#[test] -fn reasoning_collapse_finalizes_to_single_summary_line() { +fn reasoning_preceded_by_answer_keeps_order_and_drops_reasoning() { + // Answer text streamed *before* a reasoning block must stay in place and in + // order; closing the reasoning removes only the reasoning, leaving the answer. let mut app = create_test_app(); + let sentinel = jcode_tui_markdown::REASONING_SENTINEL; + app.append_streaming_text("Intro before thinking."); app.open_reasoning_region(); - app.append_reasoning_text("first\nsecond\nthird\n"); + app.append_reasoning_text("let me think\nstep two\n"); app.close_reasoning_region(None); + app.append_streaming_text("Conclusion after thinking."); - assert!(app.reasoning_collapse_active(), "collapse should start"); - - // Snapping finalizes the message to just the summary line. - app.finalize_reasoning_collapse(); - assert!(!app.reasoning_collapse_active(), "collapse cleared on finalize"); - - let content = app - .display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| m.content.clone()) - .expect("reasoning message present"); - assert!(content.contains("▸ thought"), "summary present: {content:?}"); - assert!(!content.contains("first"), "lines folded away: {content:?}"); - assert!(!content.contains("third"), "lines folded away: {content:?}"); + let text = app.streaming_text(); + assert!( + !text.contains(sentinel), + "reasoning must be fully removed: {text:?}" + ); + let intro = text.find("Intro before thinking.").expect("intro present"); + let concl = text + .find("Conclusion after thinking.") + .expect("conclusion present"); + assert!( + intro < concl, + "answer text must keep its original order: {text:?}" + ); + assert!( + !app.display_messages.iter().any(|m| m.role == "reasoning"), + "ephemeral reasoning must not create a persistent message" + ); } #[test] -fn reasoning_collapse_drops_when_target_message_replaced() { +fn multiple_reasoning_blocks_do_not_accumulate() { + // Each reasoning block is ephemeral: closing a second block (after a commit) + // must not leave any reasoning message behind from the first or second block. let mut app = create_test_app(); app.open_reasoning_region(); - app.append_reasoning_text("thinking\n"); + app.append_reasoning_text("first block thinking\n"); app.close_reasoning_region(None); - assert!(app.reasoning_collapse_active()); + app.append_streaming_text("Answer one."); + app.commit_pending_streaming_assistant_message(); - // A transcript reset must invalidate the animation target safely. - app.clear_display_messages(); - assert!(!app.reasoning_collapse_active()); - // Advancing now is a no-op and must not panic. - assert!(!app.advance_reasoning_collapse()); -} - -#[test] -fn reasoning_collapse_visible_lines_shrink_monotonically_over_time() { - use crate::tui::app::input::REASONING_COLLAPSE_DURATION; - use std::time::Duration; - - let mut app = create_test_app(); app.open_reasoning_region(); - app.append_reasoning_text("l1\nl2\nl3\nl4\nl5\nl6\n"); + app.append_reasoning_text("second block thinking\n"); app.close_reasoning_region(None); - let sentinel = jcode_tui_markdown::REASONING_SENTINEL; - let count_visible = |app: &App| -> usize { - app.display_messages - .iter() - .find(|m| m.role == "reasoning") - .map(|m| { - m.content - .split_inclusive('\n') - .filter(|seg| seg.contains(sentinel)) - .filter(|seg| !seg.contains('▸')) - .count() - }) - .unwrap_or(0) - }; - - // Sample the eased timeline; visible reasoning lines must never increase and - // must reach a single summary line (0 source lines) at/after the duration. - let dur = REASONING_COLLAPSE_DURATION; - let mut prev = usize::MAX; - for frac in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { - let elapsed = Duration::from_secs_f32(dur.as_secs_f32() * frac); - app.backdate_reasoning_collapse_for_test(elapsed) - .expect("collapse active"); - app.advance_reasoning_collapse(); - let visible = count_visible(&app); - assert!( - visible <= prev, - "visible lines must not increase: frac={frac} visible={visible} prev={prev}" - ); - prev = visible; - } - - // Past the duration the animation is finalized to the summary only. - assert!(!app.reasoning_collapse_active(), "collapse should finish"); - assert_eq!(count_visible(&app), 0, "only the summary line remains"); + let reasoning_msgs = app + .display_messages + .iter() + .filter(|m| m.role == "reasoning") + .count(); + assert_eq!( + reasoning_msgs, 0, + "reasoning must never accumulate as persistent messages" + ); + assert!( + !app.streaming_text() + .contains(jcode_tui_markdown::REASONING_SENTINEL), + "no reasoning markup should linger in the stream: {:?}", + app.streaming_text() + ); } diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index d5238330f..197195d48 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -372,8 +372,6 @@ impl App { reasoning_pending_line: String::new(), reasoning_partial_len: 0, reasoning_block_start: None, - reasoning_block_started_at: None, - reasoning_collapse: None, reload_requested: None, rebuild_requested: None, update_requested: None, @@ -775,8 +773,6 @@ impl App { reasoning_pending_line: String::new(), reasoning_partial_len: 0, reasoning_block_start: None, - reasoning_block_started_at: None, - reasoning_collapse: None, reload_requested: None, rebuild_requested: None, update_requested: None, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 5f0427843..a8e5b4e66 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -596,10 +596,6 @@ impl crate::tui::TuiState for App { self.mouse_scroll_queue != 0 } - fn reasoning_collapse_animating(&self) -> bool { - self.reasoning_collapse_active() - } - fn total_session_tokens(&self) -> Option<(u64, u64)> { // In remote mode, use tokens from server // Independent mode doesn't currently track total tokens diff --git a/crates/jcode-tui/src/tui/app/turn.rs b/crates/jcode-tui/src/tui/app/turn.rs index 6e63434d5..f5b9c5a8c 100644 --- a/crates/jcode-tui/src/tui/app/turn.rs +++ b/crates/jcode-tui/src/tui/app/turn.rs @@ -268,8 +268,6 @@ impl App { if let Some(chunk) = self.stream_buffer.flush_smooth_frame() { self.append_streaming_text(&chunk); } - // Advance the "current reasoning collapses away" animation. - self.advance_reasoning_collapse(); // Poll for background compaction completion during streaming self.poll_compaction_completion(); status_spinner_renderer.draw_full(self, terminal)?; diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index 9556c23eb..5cf53e0b3 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -217,11 +217,6 @@ pub trait TuiState { fn has_pending_mouse_scroll_animation(&self) -> bool { false } - /// Whether a "current reasoning collapses away" animation is in progress and - /// the redraw loop must keep ticking to advance it. - fn reasoning_collapse_animating(&self) -> bool { - false - } /// Optional configured keybinding label for external dictation. fn dictation_key_label(&self) -> Option; /// Time since app started (for startup animations) @@ -1287,7 +1282,6 @@ pub(crate) fn redraw_interval_with_policy( || !state.streaming_text().is_empty() || state.status_notice().is_some() || state.has_pending_mouse_scroll_animation() - || state.reasoning_collapse_animating() || state.copy_selection_edge_autoscroll_active() || state.has_notification() || rate_limit_countdown_redraw_active(state) @@ -1347,7 +1341,6 @@ pub(crate) fn periodic_redraw_required(state: &dyn TuiState) -> bool { || !state.streaming_text().is_empty() || state.status_notice().is_some() || state.has_pending_mouse_scroll_animation() - || state.reasoning_collapse_animating() || state.copy_selection_edge_autoscroll_active() || state.chat_overscroll_active() || state.has_notification() From ba8bd7df6a4164078670a6e4c0ed429077f38402 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:04:39 -0700 Subject: [PATCH 06/41] bench: verify Anthropic fast-mode granted tier + retry on 429 Capture the response usage.service_tier so we can confirm whether the priority/auto request was actually honored or silently fell back to standard (set JCODE_LOG_SERVICE_TIER=1 to print it). Add 429 retry and a cooldown gap to the essay TPS benchmark. --- crates/jcode-base/src/provider/anthropic.rs | 7 +++++ examples/bench_anthropic_essay_tps.rs | 34 +++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/jcode-base/src/provider/anthropic.rs b/crates/jcode-base/src/provider/anthropic.rs index d632f63d5..1d0a35b79 100644 --- a/crates/jcode-base/src/provider/anthropic.rs +++ b/crates/jcode-base/src/provider/anthropic.rs @@ -2100,6 +2100,12 @@ fn process_sse_event( *input_tokens = usage.input_tokens.map(|t| t as u64); *cache_read_input_tokens = usage.cache_read_input_tokens.map(|t| t as u64); *cache_creation_input_tokens = usage.cache_creation_input_tokens.map(|t| t as u64); + if let Some(tier) = usage.service_tier.as_deref() { + crate::logging::info(&format!("Anthropic granted service_tier={}", tier)); + if std::env::var("JCODE_LOG_SERVICE_TIER").is_ok() { + eprintln!("[anthropic] granted service_tier={tier}"); + } + } } } "content_block_start" => { @@ -2597,6 +2603,7 @@ struct UsageInfo { output_tokens: Option, cache_read_input_tokens: Option, cache_creation_input_tokens: Option, + service_tier: Option, } #[cfg(test)] diff --git a/examples/bench_anthropic_essay_tps.rs b/examples/bench_anthropic_essay_tps.rs index 78c8aee23..fa0fbdd16 100644 --- a/examples/bench_anthropic_essay_tps.rs +++ b/examples/bench_anthropic_essay_tps.rs @@ -5,6 +5,34 @@ use jcode::provider::Provider; use jcode::provider::anthropic::AnthropicProvider; use std::time::Instant; +async fn run_one_with_retry( + provider: &AnthropicProvider, + label: &str, + words: usize, + retries: usize, +) -> Result<()> { + let mut attempt = 0; + loop { + match run_one(provider, label, words).await { + Ok(()) => return Ok(()), + Err(e) => { + let msg = e.to_string(); + let is_rate_limit = msg.contains("429") || msg.contains("rate_limit"); + if is_rate_limit && attempt < retries { + attempt += 1; + let backoff = 30u64 * attempt as u64; + eprintln!( + "[{label}] rate limited (attempt {attempt}/{retries}); waiting {backoff}s..." + ); + tokio::time::sleep(std::time::Duration::from_secs(backoff)).await; + continue; + } + return Err(e); + } + } + } +} + async fn run_one(provider: &AnthropicProvider, label: &str, words: usize) -> Result<()> { let prompt = format!( "Write a very long essay of at least {words} words about the architecture, maintainability, reliability, performance, testing strategy, provider abstraction, TUI complexity, security model, and long-term engineering risks of a Rust terminal AI coding agent codebase like jcode. Be specific and detailed. Do not use tools. Do not stop early." @@ -90,7 +118,9 @@ async fn main() -> Result<()> { let fast = AnthropicProvider::new(); fast.set_model("claude-opus-4-8")?; fast.set_service_tier("priority")?; - run_one(&standard, "standard_only", words).await?; - run_one(&fast, "auto", words).await?; + run_one_with_retry(&standard, "standard_only", words, 4).await?; + // Cool-down gap to avoid back-to-back rate limiting between the two runs. + tokio::time::sleep(std::time::Duration::from_secs(20)).await; + run_one_with_retry(&fast, "auto", words, 4).await?; Ok(()) } From 4d8c06d71aaa90415f8c4ab49cf1f4f97a876b1b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:48:15 -0700 Subject: [PATCH 07/41] refactor(server): extract FileTouchService for file-touch tracking Introduce FileTouchService owning the two file-touch maps (path->accesses and session->paths) behind intention-revealing async methods (record_touch, accesses_for_path, sorted_file_strings_for_session, snapshot, reverse_snapshot, clear_session, expire_older_than). Replace the two raw Arc> fields on Server with a single file_touch: FileTouchService field, thread the handle through ServerRuntime/handle_client/handle_debug_client instead of passing the raw maps, and route all non-test server/* call sites through the service. Removes the now-unused swarm::remove_session_file_touches helper. Behavior-preserving: same locking order and semantics retained. --- crates/jcode-app-core/src/server.rs | 80 ++++------ .../src/server/client_comm_context.rs | 9 +- .../src/server/client_disconnect_cleanup.rs | 11 +- .../src/server/client_lifecycle.rs | 31 ++-- .../src/server/client_lightweight_control.rs | 15 +- .../src/server/client_session.rs | 28 ++-- crates/jcode-app-core/src/server/comm_sync.rs | 21 +-- crates/jcode-app-core/src/server/debug.rs | 11 +- .../src/server/debug_server_state.rs | 18 +-- .../src/server/debug_swarm_read.rs | 12 +- .../src/server/file_touch_service.rs | 147 ++++++++++++++++++ crates/jcode-app-core/src/server/runtime.rs | 15 +- crates/jcode-app-core/src/server/swarm.rs | 33 +--- 13 files changed, 240 insertions(+), 191 deletions(-) create mode 100644 crates/jcode-app-core/src/server/file_touch_service.rs diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs index 5d8ee1043..b02832c5f 100644 --- a/crates/jcode-app-core/src/server.rs +++ b/crates/jcode-app-core/src/server.rs @@ -58,7 +58,7 @@ use self::runtime::ServerRuntime; use self::swarm::{ broadcast_swarm_plan, broadcast_swarm_plan_with_previous, broadcast_swarm_status, record_swarm_event, record_swarm_event_for_session, refresh_swarm_task_staleness, - remove_plan_participant, remove_session_file_touches, remove_session_from_swarm, + remove_plan_participant, remove_session_from_swarm, rename_plan_participant, run_swarm_message, update_member_status, update_member_status_with_report, }; @@ -361,6 +361,9 @@ use self::util::{ mod file_activity; use self::file_activity::file_activity_scope_label; +mod file_touch_service; +pub(crate) use self::file_touch_service::FileTouchService; + #[cfg(test)] mod socket_tests; @@ -402,10 +405,8 @@ pub struct Server { client_count: Arc>, /// Connected client mapping (client_id -> session_id) client_connections: Arc>>, - /// Track file touches: path -> list of accesses - file_touches: Arc>>>, - /// Reverse index for file touches: session_id -> touched paths - files_touched_by_session: Arc>>>, + /// File-touch tracking service (forward path index + reverse session index) + file_touch: FileTouchService, /// Shared ownership of core swarm coordination state. swarm_state: SwarmState, /// Shared context by swarm (swarm_id -> key -> SharedContext) @@ -489,8 +490,7 @@ impl Server { session_id: Arc::new(RwLock::new(String::new())), client_count: Arc::new(RwLock::new(0)), client_connections: Arc::new(RwLock::new(HashMap::new())), - file_touches: Arc::new(RwLock::new(HashMap::new())), - files_touched_by_session: Arc::new(RwLock::new(HashMap::new())), + file_touch: FileTouchService::new(), swarm_state: SwarmState::new( restored_swarm_members, restored_swarms_by_id, @@ -1005,8 +1005,7 @@ impl Server { } // Spawn the bus monitor for swarm coordination - let monitor_file_touches = Arc::clone(&self.file_touches); - let monitor_files_touched_by_session = Arc::clone(&self.files_touched_by_session); + let monitor_file_touch = self.file_touch.clone(); let monitor_swarm_members = Arc::clone(&self.swarm_state.members); let monitor_swarms_by_id = Arc::clone(&self.swarm_state.swarms_by_id); let monitor_swarm_plans = Arc::clone(&self.swarm_state.plans); @@ -1019,8 +1018,7 @@ impl Server { let monitor_swarm_event_tx = self.swarm_event_tx.clone(); tokio::spawn(async move { Self::monitor_bus( - monitor_file_touches, - monitor_files_touched_by_session, + monitor_file_touch, monitor_swarm_members, monitor_swarms_by_id, monitor_swarm_plans, @@ -1474,8 +1472,7 @@ impl Server { reason = "bus monitor needs file state, swarm state, sessions, queues, and event history sinks" )] async fn monitor_bus( - file_touches: Arc>>>, - files_touched_by_session: Arc>>>, + file_touch: FileTouchService, swarm_members: Arc>>, swarms_by_id: Arc>>>, _swarm_plans: Arc>>, @@ -1495,23 +1492,7 @@ impl Server { loop { // Periodic cleanup of expired file touches if last_cleanup.elapsed() > CLEANUP_INTERVAL { - let mut touches = file_touches.write().await; - let now = Instant::now(); - touches.retain(|_, accesses| { - accesses.retain(|a| now.duration_since(a.timestamp) < TOUCH_EXPIRY); - !accesses.is_empty() - }); - let mut rebuilt_reverse_index: HashMap> = HashMap::new(); - for (path, accesses) in touches.iter() { - for access in accesses { - rebuilt_reverse_index - .entry(access.session_id.clone()) - .or_default() - .insert(path.clone()); - } - } - drop(touches); - *files_touched_by_session.write().await = rebuilt_reverse_index; + file_touch.expire_older_than(TOUCH_EXPIRY).await; last_cleanup = Instant::now(); } @@ -1521,26 +1502,20 @@ impl Server { let session_id = touch.session_id.clone(); // Record this touch - { - let mut touches = file_touches.write().await; - let accesses = touches.entry(path.clone()).or_insert_with(Vec::new); - accesses.push(FileAccess { - session_id: session_id.clone(), - op: touch.op.clone(), - timestamp: Instant::now(), - absolute_time: std::time::SystemTime::now(), - intent: touch.intent.clone(), - summary: touch.summary.clone(), - detail: touch.detail.clone(), - }); - } - { - let mut reverse_index = files_touched_by_session.write().await; - reverse_index - .entry(session_id.clone()) - .or_default() - .insert(path.clone()); - } + file_touch + .record_touch( + path.clone(), + FileAccess { + session_id: session_id.clone(), + op: touch.op.clone(), + timestamp: Instant::now(), + absolute_time: std::time::SystemTime::now(), + intent: touch.intent.clone(), + summary: touch.summary.clone(), + detail: touch.detail.clone(), + }, + ) + .await; // Record event for subscription { @@ -1603,12 +1578,11 @@ impl Server { )); } let previous_touches: Vec = if is_modification { - let touches = file_touches.read().await; - if let Some(accesses) = touches.get(&path) { + if let Some(accesses) = file_touch.accesses_for_path(&path).await { let swarm_session_ids_set: HashSet = swarm_session_ids.iter().cloned().collect(); let result = - latest_peer_touches(accesses, &session_id, &swarm_session_ids_set); + latest_peer_touches(&accesses, &session_id, &swarm_session_ids_set); crate::logging::info(&format!( "[file-activity] {} prior peer touches ({} total accesses)", result.len(), diff --git a/crates/jcode-app-core/src/server/client_comm_context.rs b/crates/jcode-app-core/src/server/client_comm_context.rs index b99a1b56a..6bf43fe1c 100644 --- a/crates/jcode-app-core/src/server/client_comm_context.rs +++ b/crates/jcode-app-core/src/server/client_comm_context.rs @@ -1,11 +1,10 @@ use super::{ - SharedContext, SwarmEvent, SwarmEventType, SwarmMember, fanout_session_event, - record_swarm_event, + FileTouchService, SharedContext, SwarmEvent, SwarmEventType, SwarmMember, + fanout_session_event, record_swarm_event, }; use super::debug::ClientConnectionInfo; use crate::protocol::{AgentInfo, ContextEntry, NotificationType, ServerEvent}; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::sync::{RwLock, broadcast, mpsc}; @@ -198,7 +197,7 @@ pub(super) async fn handle_comm_list( client_event_tx: &mpsc::UnboundedSender, swarm_members: &Arc>>, swarms_by_id: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, sessions: &super::SessionAgents, client_connections: &Arc>>, ) { @@ -232,7 +231,7 @@ pub(super) async fn handle_comm_list( let statics: Vec = { let members = swarm_members.read().await; - let touches = files_touched_by_session.read().await; + let touches = file_touch.reverse_snapshot().await; swarm_session_ids .iter() .filter_map(|sid| { diff --git a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs index f2d596775..24af18999 100644 --- a/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs +++ b/crates/jcode-app-core/src/server/client_disconnect_cleanup.rs @@ -1,14 +1,13 @@ use super::{ - ClientConnectionInfo, ClientDebugState, FileAccess, SessionInterruptQueues, SwarmEvent, + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, SwarmEventType, SwarmMember, VersionedPlan, record_swarm_event, - remove_session_channel_subscriptions, remove_session_file_touches, remove_session_from_swarm, + remove_session_channel_subscriptions, remove_session_from_swarm, remove_session_interrupt_queue, unregister_session_event_sender, update_member_status, }; use crate::agent::Agent; use anyhow::Result; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::sync::{Mutex, RwLock, broadcast}; @@ -62,8 +61,7 @@ pub(super) async fn cleanup_client_connection( swarms_by_id: &Arc>>>, swarm_coordinators: &Arc>>, swarm_plans: &Arc>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, client_debug_state: &Arc>, @@ -244,8 +242,7 @@ pub(super) async fn cleanup_client_connection( channel_subscriptions_by_session, ) .await; - remove_session_file_touches(client_session_id, file_touches, files_touched_by_session) - .await; + file_touch.clear_session(client_session_id).await; } { diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs index 0455b09bd..a6d4100e9 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle.rs @@ -45,10 +45,11 @@ use super::provider_control::{ try_available_models_updated_event, }; use super::{ - AwaitMembersRuntime, ClientConnectionInfo, ClientDebugState, FileAccess, SessionControlHandle, - SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, SwarmMutationRuntime, - VersionedPlan, format_structured_completion_report, register_session_interrupt_queue, - truncate_detail, update_member_status, update_member_status_with_report, + AwaitMembersRuntime, ClientConnectionInfo, ClientDebugState, FileTouchService, + SessionControlHandle, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, + SwarmMutationRuntime, VersionedPlan, format_structured_completion_report, + register_session_interrupt_queue, truncate_detail, update_member_status, + update_member_status_with_report, }; use crate::agent::Agent; use crate::bus::{Bus, BusEvent}; @@ -61,7 +62,6 @@ use anyhow::Result; use futures::FutureExt; use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource, StreamError}; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -315,8 +315,7 @@ pub(super) async fn handle_client( shared_context: Arc>>>, swarm_plans: Arc>>, swarm_coordinators: Arc>>, - file_touches: Arc>>>, - files_touched_by_session: Arc>>>, + file_touch: FileTouchService, channel_subscriptions: ChannelSubscriptions, channel_subscriptions_by_session: ChannelSubscriptions, client_debug_state: Arc>, @@ -371,7 +370,7 @@ pub(super) async fn handle_client( shared_context: &shared_context, swarm_plans: &swarm_plans, swarm_coordinators: &swarm_coordinators, - files_touched_by_session: &files_touched_by_session, + file_touch: &file_touch, channel_subscriptions: &channel_subscriptions, channel_subscriptions_by_session: &channel_subscriptions_by_session, client_connections: &client_connections, @@ -1102,8 +1101,7 @@ pub(super) async fn handle_client( &client_connections, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, @@ -1285,8 +1283,7 @@ pub(super) async fn handle_client( &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, @@ -1513,8 +1510,7 @@ pub(super) async fn handle_client( &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, @@ -1923,7 +1919,7 @@ pub(super) async fn handle_client( &client_event_tx, &swarm_members, &swarms_by_id, - &files_touched_by_session, + &file_touch, &sessions, &client_connections, ) @@ -2157,7 +2153,7 @@ pub(super) async fn handle_client( &sessions, &swarm_members, &client_connections, - &files_touched_by_session, + &file_touch, &client_event_tx, ) .await; @@ -2449,8 +2445,7 @@ pub(super) async fn handle_client( &swarms_by_id, &swarm_coordinators, &swarm_plans, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &client_debug_state, diff --git a/crates/jcode-app-core/src/server/client_lightweight_control.rs b/crates/jcode-app-core/src/server/client_lightweight_control.rs index 8ab3ac0e8..4dad99b74 100644 --- a/crates/jcode-app-core/src/server/client_lightweight_control.rs +++ b/crates/jcode-app-core/src/server/client_lightweight_control.rs @@ -18,9 +18,9 @@ use super::comm_sync::{ handle_comm_resync_plan, handle_comm_status, handle_comm_summary, }; use super::{ - AwaitMembersRuntime, ChannelSubscriptions, ClientConnectionInfo, SessionAgents, - SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, SwarmMutationRuntime, - VersionedPlan, format_structured_completion_report, truncate_detail, + AwaitMembersRuntime, ChannelSubscriptions, ClientConnectionInfo, FileTouchService, + SessionAgents, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, + SwarmMutationRuntime, VersionedPlan, format_structured_completion_report, truncate_detail, update_member_status_with_report, }; use crate::config::SwarmSpawnMode; @@ -28,7 +28,6 @@ use crate::protocol::{Request, ServerEvent}; use crate::provider::Provider; use anyhow::Result; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; @@ -64,7 +63,7 @@ pub(super) struct LightweightControlContext<'a> { pub(super) shared_context: &'a Arc>>>, pub(super) swarm_plans: &'a Arc>>, pub(super) swarm_coordinators: &'a Arc>>, - pub(super) files_touched_by_session: &'a Arc>>>, + pub(super) file_touch: &'a FileTouchService, pub(super) channel_subscriptions: &'a ChannelSubscriptions, pub(super) channel_subscriptions_by_session: &'a ChannelSubscriptions, pub(super) client_connections: &'a Arc>>, @@ -91,7 +90,7 @@ pub(super) async fn handle_lightweight_control_request( shared_context, swarm_plans, swarm_coordinators, - files_touched_by_session, + file_touch, channel_subscriptions, channel_subscriptions_by_session, client_connections, @@ -203,7 +202,7 @@ pub(super) async fn handle_lightweight_control_request( &client_event_tx, swarm_members, swarms_by_id, - files_touched_by_session, + file_touch, sessions, client_connections, ) @@ -427,7 +426,7 @@ pub(super) async fn handle_lightweight_control_request( sessions, swarm_members, client_connections, - files_touched_by_session, + file_touch, &client_event_tx, ) .await; diff --git a/crates/jcode-app-core/src/server/client_session.rs b/crates/jcode-app-core/src/server/client_session.rs index 01b229fd1..59a0e8bfd 100644 --- a/crates/jcode-app-core/src/server/client_session.rs +++ b/crates/jcode-app-core/src/server/client_session.rs @@ -2,13 +2,12 @@ use super::client_state::{handle_get_history, spawn_model_prefetch_update}; use super::{ - ClientConnectionInfo, ClientDebugState, FileAccess, SessionInterruptQueues, SwarmEvent, + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, SwarmMember, SwarmState, VersionedPlan, broadcast_swarm_status, fanout_live_client_event, persist_swarm_state_for, register_session_event_sender, register_session_interrupt_queue, - remove_plan_participant, remove_session_channel_subscriptions, remove_session_file_touches, - remove_session_from_swarm, remove_session_interrupt_queue, rename_plan_participant, - rename_session_interrupt_queue, swarm_id_for_dir, unregister_session_event_sender, - update_member_status, + remove_plan_participant, remove_session_channel_subscriptions, remove_session_from_swarm, + remove_session_interrupt_queue, rename_plan_participant, rename_session_interrupt_queue, + swarm_id_for_dir, unregister_session_event_sender, update_member_status, }; use crate::agent::Agent; use crate::message::ContentBlock; @@ -134,8 +133,7 @@ pub(super) async fn handle_clear_session( client_connections: &Arc>>, swarm_members: &Arc>>, swarms_by_id: &Arc>>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, swarm_plans: &Arc>>, @@ -228,7 +226,7 @@ pub(super) async fn handle_clear_session( swarm.insert(new_id.clone()); } } - remove_session_file_touches(client_session_id, file_touches, files_touched_by_session).await; + file_touch.clear_session(client_session_id).await; remove_session_channel_subscriptions( client_session_id, channel_subscriptions, @@ -762,8 +760,7 @@ async fn cleanup_detached_source_session_if_unused( client_connections: &Arc>>, swarm_members: &Arc>>, swarms_by_id: &Arc>>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, swarm_plans: &Arc>>, @@ -809,7 +806,7 @@ async fn cleanup_detached_source_session_if_unused( channel_subscriptions_by_session, ) .await; - remove_session_file_touches(old_session_id, file_touches, files_touched_by_session).await; + file_touch.clear_session(old_session_id).await; let removed_swarm_id = { let mut members = swarm_members.write().await; @@ -850,8 +847,7 @@ pub(super) async fn handle_resume_session( client_debug_state: &Arc>, swarm_members: &Arc>>, swarms_by_id: &Arc>>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, swarm_plans: &Arc>>, @@ -939,8 +935,7 @@ pub(super) async fn handle_resume_session( client_connections, swarm_members, swarms_by_id, - file_touches, - files_touched_by_session, + file_touch, channel_subscriptions, channel_subscriptions_by_session, swarm_plans, @@ -1287,8 +1282,7 @@ pub(super) async fn handle_resume_session( channel_subscriptions_by_session, ) .await; - remove_session_file_touches(&old_session_id, file_touches, files_touched_by_session) - .await; + file_touch.clear_session(&old_session_id).await; { let mut coordinators = swarm_coordinators.write().await; for coordinator in coordinators.values_mut() { diff --git a/crates/jcode-app-core/src/server/comm_sync.rs b/crates/jcode-app-core/src/server/comm_sync.rs index bd18c44a9..ebbe94d88 100644 --- a/crates/jcode-app-core/src/server/comm_sync.rs +++ b/crates/jcode-app-core/src/server/comm_sync.rs @@ -1,20 +1,17 @@ use super::{ - ClientConnectionInfo, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan, - broadcast_swarm_plan, persist_swarm_state_for, record_swarm_event, + ClientConnectionInfo, FileTouchService, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, + VersionedPlan, broadcast_swarm_plan, persist_swarm_state_for, record_swarm_event, }; use crate::agent::Agent; use crate::protocol::{ AgentStatusSnapshot, NotificationType, PlanGraphStatus, ServerEvent, SessionActivitySnapshot, }; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; type SessionAgents = Arc>>>>; -type SessionFilesTouched = Arc>>>; - pub(super) struct CommResyncPlanContext<'a> { pub(super) client_event_tx: &'a mpsc::UnboundedSender, pub(super) swarm_members: &'a Arc>>, @@ -255,7 +252,7 @@ pub(super) async fn handle_comm_status( sessions: &SessionAgents, swarm_members: &Arc>>, client_connections: &Arc>>, - files_touched_by_session: &SessionFilesTouched, + file_touch: &FileTouchService, client_event_tx: &mpsc::UnboundedSender, ) { if !ensure_same_swarm_access( @@ -281,17 +278,7 @@ pub(super) async fn handle_comm_status( return; }; - let files_touched = { - let touches = files_touched_by_session.read().await; - let mut files: Vec = touches - .get(&target_session) - .into_iter() - .flat_map(|paths| paths.iter()) - .map(|path| path.display().to_string()) - .collect(); - files.sort(); - files - }; + let files_touched = file_touch.sorted_file_strings_for_session(&target_session).await; let activity = { let connections = client_connections.read().await; diff --git a/crates/jcode-app-core/src/server/debug.rs b/crates/jcode-app-core/src/server/debug.rs index 97e49d3ed..496798cc5 100644 --- a/crates/jcode-app-core/src/server/debug.rs +++ b/crates/jcode-app-core/src/server/debug.rs @@ -18,7 +18,7 @@ use super::debug_swarm_read::maybe_handle_swarm_read_command; use super::debug_swarm_write::{DebugSwarmWriteContext, maybe_handle_swarm_write_command}; use super::debug_testers::execute_tester_command; use super::{ - FileAccess, ServerIdentity, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, + FileTouchService, ServerIdentity, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, debug_control_allowed, fanout_session_event, }; use crate::agent::Agent; @@ -29,7 +29,6 @@ use crate::transport::Stream; use anyhow::Result; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -256,8 +255,7 @@ pub(super) async fn handle_debug_client( shared_context: Arc>>>, swarm_plans: Arc>>, swarm_coordinators: Arc>>, - file_touches: Arc>>>, - files_touched_by_session: Arc>>>, + file_touch: FileTouchService, channel_subscriptions: ChannelSubscriptions, channel_subscriptions_by_session: ChannelSubscriptions, client_debug_state: Arc>, @@ -457,8 +455,7 @@ pub(super) async fn handle_debug_client( &shared_context, &swarm_plans, &swarm_coordinators, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &debug_jobs, @@ -477,7 +474,7 @@ pub(super) async fn handle_debug_client( &shared_context, &swarm_plans, &swarm_coordinators, - &file_touches, + &file_touch, &channel_subscriptions, &server_identity, ) diff --git a/crates/jcode-app-core/src/server/debug_server_state.rs b/crates/jcode-app-core/src/server/debug_server_state.rs index 2abab4aaa..1ea2e3a87 100644 --- a/crates/jcode-app-core/src/server/debug_server_state.rs +++ b/crates/jcode-app-core/src/server/debug_server_state.rs @@ -1,11 +1,10 @@ use super::{ - ClientConnectionInfo, ClientDebugState, DebugJob, FileAccess, ServerIdentity, - SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, + ClientConnectionInfo, ClientDebugState, DebugJob, FileAccess, FileTouchService, + ServerIdentity, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, }; use crate::agent::Agent; use anyhow::Result; use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::sync::{Mutex, RwLock}; @@ -29,8 +28,7 @@ pub(super) async fn maybe_handle_server_state_command( shared_context: &Arc>>>, swarm_plans: &Arc>>, swarm_coordinators: &Arc>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, debug_jobs: &Arc>>, @@ -124,8 +122,7 @@ pub(super) async fn maybe_handle_server_state_command( shared_context, swarm_plans, swarm_coordinators, - file_touches, - files_touched_by_session, + file_touch, channel_subscriptions, channel_subscriptions_by_session, debug_jobs, @@ -262,8 +259,7 @@ async fn build_server_memory_payload( shared_context: &Arc>>>, swarm_plans: &Arc>>, swarm_coordinators: &Arc>>, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, channel_subscriptions_by_session: &ChannelSubscriptions, debug_jobs: &Arc>>, @@ -460,7 +456,7 @@ async fn build_server_memory_payload( .sum(); drop(coordinators); - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let file_touch_path_count = touches.len(); let file_touch_entry_count: usize = touches.values().map(|entries| entries.len()).sum(); let file_touch_estimate_bytes: usize = touches @@ -475,7 +471,7 @@ async fn build_server_memory_payload( .sum(); drop(touches); - let touched_by_session = files_touched_by_session.read().await; + let touched_by_session = file_touch.reverse_snapshot().await; let touched_session_count = touched_by_session.len(); let touched_session_estimate_bytes: usize = touched_by_session .iter() diff --git a/crates/jcode-app-core/src/server/debug_swarm_read.rs b/crates/jcode-app-core/src/server/debug_swarm_read.rs index 42e4c1e66..ee25a47b4 100644 --- a/crates/jcode-app-core/src/server/debug_swarm_read.rs +++ b/crates/jcode-app-core/src/server/debug_swarm_read.rs @@ -1,6 +1,6 @@ use super::swarm_channels::list_channels_for_swarm; use super::{ - FileAccess, ServerIdentity, SharedContext, SwarmMember, SwarmState, VersionedPlan, + FileTouchService, ServerIdentity, SharedContext, SwarmMember, SwarmState, VersionedPlan, git_common_dir_for, swarm_id_for_dir, }; use crate::agent::Agent; @@ -26,7 +26,7 @@ pub(super) async fn maybe_handle_swarm_read_command( shared_context: &Arc>>>, swarm_plans: &Arc>>, swarm_coordinators: &Arc>>, - file_touches: &Arc>>>, + file_touch: &FileTouchService, channel_subscriptions: &ChannelSubscriptions, server_identity: &ServerIdentity, ) -> Result> { @@ -388,7 +388,7 @@ pub(super) async fn maybe_handle_swarm_read_command( } if cmd == "swarm:touches" { - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let members = swarm_members.read().await; let mut out: Vec = Vec::new(); for (path, accesses) in touches.iter() { @@ -419,7 +419,7 @@ pub(super) async fn maybe_handle_swarm_read_command( if cmd.starts_with("swarm:touches:") { let arg = cmd.strip_prefix("swarm:touches:").unwrap_or("").trim(); - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let members = swarm_members.read().await; let output = if arg.starts_with("swarm:") { let swarm_id = arg.strip_prefix("swarm:").unwrap_or(""); @@ -485,7 +485,7 @@ pub(super) async fn maybe_handle_swarm_read_command( } if cmd == "swarm:conflicts" { - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let members = swarm_members.read().await; let mut out: Vec = Vec::new(); for (path, accesses) in touches.iter() { @@ -615,7 +615,7 @@ pub(super) async fn maybe_handle_swarm_read_command( let members = swarm_members.read().await; let plans = swarm_plans.read().await; let ctx = shared_context.read().await; - let touches = file_touches.read().await; + let touches = file_touch.snapshot().await; let output = if let Some(session_ids) = swarms.get(swarm_id) { let coordinator = coordinators.get(swarm_id); diff --git a/crates/jcode-app-core/src/server/file_touch_service.rs b/crates/jcode-app-core/src/server/file_touch_service.rs new file mode 100644 index 000000000..ba0a0cabc --- /dev/null +++ b/crates/jcode-app-core/src/server/file_touch_service.rs @@ -0,0 +1,147 @@ +//! Service handle that owns the server's file-touch tracking state. +//! +//! Historically the [`Server`](super::Server) struct held two raw +//! `Arc>` maps for file-touch tracking and every call site reached +//! directly into them. This service consolidates that state behind +//! intention-revealing methods so the rest of the server no longer needs to +//! know the internal map shapes or locking order. +//! +//! The two indexes are kept in sync: +//! * forward: `path -> chronological accesses` +//! * reverse: `session_id -> set of touched paths` + +use super::FileAccess; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// Shared ownership of the server's file-touch tracking indexes. +/// +/// Cloning is cheap: every clone shares the same underlying `Arc`-backed maps, +/// matching the previous behavior where the raw `Arc>` fields were +/// cloned and passed around. +#[derive(Clone)] +pub(crate) struct FileTouchService { + /// Forward index: path -> list of accesses (chronological order). + touches: Arc>>>, + /// Reverse index: session_id -> set of paths the session has touched. + by_session: Arc>>>, +} + +impl FileTouchService { + /// Create an empty file-touch tracker. + pub(crate) fn new() -> Self { + Self { + touches: Arc::new(RwLock::new(HashMap::new())), + by_session: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Record a single file access, updating both the forward and reverse + /// indexes. The forward index is updated first (and its lock released) + /// before the reverse index, preserving the original locking order. + pub(crate) async fn record_touch(&self, path: PathBuf, access: FileAccess) { + let session_id = access.session_id.clone(); + { + let mut touches = self.touches.write().await; + touches + .entry(path.clone()) + .or_insert_with(Vec::new) + .push(access); + } + { + let mut by_session = self.by_session.write().await; + by_session.entry(session_id).or_default().insert(path); + } + } + + /// Cloned snapshot of all accesses recorded for `path`, or `None` if the + /// path has not been touched. Callers rely on the `Some`/`None` distinction + /// (e.g. for logging "no touches yet" vs computing peer touches). + pub(crate) async fn accesses_for_path(&self, path: &Path) -> Option> { + self.touches.read().await.get(path).cloned() + } + + /// Sorted, display-formatted list of the distinct files a session has + /// touched (empty if the session has touched nothing). + pub(crate) async fn sorted_file_strings_for_session(&self, session_id: &str) -> Vec { + let by_session = self.by_session.read().await; + let mut files: Vec = by_session + .get(session_id) + .into_iter() + .flat_map(|paths| paths.iter()) + .map(|path| path.display().to_string()) + .collect(); + files.sort(); + files + } + + /// Cloned snapshot of the entire forward (`path -> accesses`) index. + /// + /// Used by read-only reporting paths (debug commands, memory accounting) + /// that need to iterate the whole map. + pub(crate) async fn snapshot(&self) -> HashMap> { + self.touches.read().await.clone() + } + + /// Cloned snapshot of the reverse (`session_id -> paths`) index. + pub(crate) async fn reverse_snapshot(&self) -> HashMap> { + self.by_session.read().await.clone() + } + + /// Remove every touch recorded for a session from both indexes. + /// + /// Uses the reverse index to bound the forward-index work to only the + /// paths the session actually touched, falling back to a full scan if the + /// reverse entry is missing. + pub(crate) async fn clear_session(&self, session_id: &str) { + let touched_paths = { + let mut reverse = self.by_session.write().await; + reverse.remove(session_id) + }; + + let mut touches = self.touches.write().await; + if let Some(paths) = touched_paths { + for path in paths { + let mut remove_path = false; + if let Some(accesses) = touches.get_mut(&path) { + accesses.retain(|access| access.session_id != session_id); + remove_path = accesses.is_empty(); + } + if remove_path { + touches.remove(&path); + } + } + return; + } + + touches.retain(|_, accesses| { + accesses.retain(|access| access.session_id != session_id); + !accesses.is_empty() + }); + } + + /// Drop accesses older than `max_age` and rebuild the reverse index from the + /// surviving forward entries. + pub(crate) async fn expire_older_than(&self, max_age: Duration) { + let mut touches = self.touches.write().await; + let now = Instant::now(); + touches.retain(|_, accesses| { + accesses.retain(|access| now.duration_since(access.timestamp) < max_age); + !accesses.is_empty() + }); + let mut rebuilt_reverse_index: HashMap> = HashMap::new(); + for (path, accesses) in touches.iter() { + for access in accesses { + rebuilt_reverse_index + .entry(access.session_id.clone()) + .or_default() + .insert(path.clone()); + } + } + drop(touches); + *self.by_session.write().await = rebuilt_reverse_index; + } +} diff --git a/crates/jcode-app-core/src/server/runtime.rs b/crates/jcode-app-core/src/server/runtime.rs index 54b8d31b6..4536a8d23 100644 --- a/crates/jcode-app-core/src/server/runtime.rs +++ b/crates/jcode-app-core/src/server/runtime.rs @@ -3,7 +3,7 @@ use super::debug::{ClientConnectionInfo, ClientDebugState, handle_debug_client}; use super::debug_jobs::DebugJob; use super::util::get_shared_mcp_pool; use super::{ - AwaitMembersRuntime, FileAccess, ServerIdentity, SessionInterruptQueues, SharedContext, + AwaitMembersRuntime, FileTouchService, ServerIdentity, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMutationRuntime, SwarmState, }; use crate::agent::Agent; @@ -14,7 +14,6 @@ use crate::provider::Provider; use crate::transport::{Listener, Stream}; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::time::Instant; @@ -33,8 +32,7 @@ pub(super) struct ServerRuntime { client_connections: Arc>>, swarm_state: SwarmState, shared_context: Arc>>>, - file_touches: Arc>>>, - files_touched_by_session: Arc>>>, + file_touch: FileTouchService, channel_subscriptions: ChannelSubscriptions, channel_subscriptions_by_session: ChannelSubscriptions, client_debug_state: Arc>, @@ -66,8 +64,7 @@ impl ServerRuntime { client_connections: Arc::clone(&server.client_connections), swarm_state: server.swarm_state.clone(), shared_context: Arc::clone(&server.shared_context), - file_touches: Arc::clone(&server.file_touches), - files_touched_by_session: Arc::clone(&server.files_touched_by_session), + file_touch: server.file_touch.clone(), channel_subscriptions: Arc::clone(&server.channel_subscriptions), channel_subscriptions_by_session: Arc::clone(&server.channel_subscriptions_by_session), client_debug_state: Arc::clone(&server.client_debug_state), @@ -217,8 +214,7 @@ impl ServerRuntime { Arc::clone(&self.shared_context), Arc::clone(&self.swarm_state.plans), Arc::clone(&self.swarm_state.coordinators), - Arc::clone(&self.file_touches), - Arc::clone(&self.files_touched_by_session), + self.file_touch.clone(), Arc::clone(&self.channel_subscriptions), Arc::clone(&self.channel_subscriptions_by_session), Arc::clone(&self.client_debug_state), @@ -262,8 +258,7 @@ impl ServerRuntime { Arc::clone(&self.shared_context), Arc::clone(&self.swarm_state.plans), Arc::clone(&self.swarm_state.coordinators), - Arc::clone(&self.file_touches), - Arc::clone(&self.files_touched_by_session), + self.file_touch.clone(), Arc::clone(&self.channel_subscriptions), Arc::clone(&self.channel_subscriptions_by_session), Arc::clone(&self.client_debug_state), diff --git a/crates/jcode-app-core/src/server/swarm.rs b/crates/jcode-app-core/src/server/swarm.rs index a53be2419..b29380418 100644 --- a/crates/jcode-app-core/src/server/swarm.rs +++ b/crates/jcode-app-core/src/server/swarm.rs @@ -1,5 +1,5 @@ use super::state::{MAX_EVENT_HISTORY, fanout_session_event}; -use super::{FileAccess, SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan}; +use super::{SwarmEvent, SwarmEventType, SwarmMember, SwarmState, VersionedPlan}; use super::{persist_swarm_state_for, remove_persisted_swarm_state_for}; use crate::agent::Agent; use crate::plan::{PlanItem, newly_ready_item_ids}; @@ -497,37 +497,6 @@ pub(super) async fn remove_plan_participant( } } -pub(super) async fn remove_session_file_touches( - session_id: &str, - file_touches: &Arc>>>, - files_touched_by_session: &Arc>>>, -) { - let touched_paths = { - let mut reverse = files_touched_by_session.write().await; - reverse.remove(session_id) - }; - - let mut touches = file_touches.write().await; - if let Some(paths) = touched_paths { - for path in paths { - let mut remove_path = false; - if let Some(accesses) = touches.get_mut(&path) { - accesses.retain(|access| access.session_id != session_id); - remove_path = accesses.is_empty(); - } - if remove_path { - touches.remove(&path); - } - } - return; - } - - touches.retain(|_, accesses| { - accesses.retain(|access| access.session_id != session_id); - !accesses.is_empty() - }); -} - pub(super) async fn remove_session_from_swarm( session_id: &str, swarm_id: &str, From 7dfac916514bb83c61db2d0fa10aa211a4976685 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:52:05 -0700 Subject: [PATCH 08/41] test(server): migrate file-touch tests to FileTouchService Update test setup in client_comm_tests, client_lifecycle_tests, and the client_session_tests suite to construct FileTouchService::new() and pass the handle instead of the raw file_touches/files_touched_by_session maps. --- crates/jcode-app-core/src/server/client_comm_tests.rs | 4 ++-- .../src/server/client_lifecycle_tests.rs | 6 ++---- .../jcode-app-core/src/server/client_session_tests.rs | 3 +-- .../src/server/client_session_tests/clear.rs | 7 ++----- .../resume/attach_without_local_history.rs | 7 ++----- .../resume/busy_existing_attach.rs | 7 ++----- .../resume/different_client_attach.rs | 7 ++----- .../resume/live_events_before_history.rs | 10 +++------- .../resume/multiple_live_attach.rs | 7 ++----- .../resume/reconnect_takeover_with_history.rs | 7 ++----- .../resume/same_client_takeover.rs | 7 ++----- 11 files changed, 22 insertions(+), 50 deletions(-) diff --git a/crates/jcode-app-core/src/server/client_comm_tests.rs b/crates/jcode-app-core/src/server/client_comm_tests.rs index 35bf65c4a..04f19bdb5 100644 --- a/crates/jcode-app-core/src/server/client_comm_tests.rs +++ b/crates/jcode-app-core/src/server/client_comm_tests.rs @@ -402,7 +402,7 @@ async fn comm_list_includes_member_status_and_detail() { swarm_id, HashSet::from([requester_id.clone(), peer_id.clone()]), )]))); - let file_touches = Arc::new(RwLock::new(HashMap::new())); + let file_touch = crate::server::FileTouchService::new(); let sessions = Arc::new(RwLock::new(HashMap::from([ (requester_id.clone(), requester.clone()), (peer_id.clone(), peer.clone()), @@ -415,7 +415,7 @@ async fn comm_list_includes_member_status_and_detail() { &client_event_tx, &swarm_members, &swarms_by_id, - &file_touches, + &file_touch, &sessions, &client_connections, ) diff --git a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs index c02140f5e..3d2eef902 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle_tests.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle_tests.rs @@ -645,8 +645,7 @@ async fn lightweight_comm_request_skips_full_session_initialization() { let shared_context = Arc::new(RwLock::new(HashMap::new())); let swarm_plans = Arc::new(RwLock::new(HashMap::new())); let swarm_coordinators = Arc::new(RwLock::new(HashMap::new())); - let file_touches = Arc::new(RwLock::new(HashMap::new())); - let files_touched_by_session = Arc::new(RwLock::new(HashMap::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::new())); let channel_subscriptions_by_session = Arc::new(RwLock::new(HashMap::new())); let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); @@ -674,8 +673,7 @@ async fn lightweight_comm_request_skips_full_session_initialization() { shared_context, swarm_plans, swarm_coordinators, - file_touches, - files_touched_by_session, + file_touch, channel_subscriptions, channel_subscriptions_by_session, client_debug_state, diff --git a/crates/jcode-app-core/src/server/client_session_tests.rs b/crates/jcode-app-core/src/server/client_session_tests.rs index d8fd02226..36af63c14 100644 --- a/crates/jcode-app-core/src/server/client_session_tests.rs +++ b/crates/jcode-app-core/src/server/client_session_tests.rs @@ -9,7 +9,7 @@ use crate::message::{Message, ToolDefinition}; use crate::protocol::ServerEvent; use crate::provider::{EventStream, Provider}; use crate::server::{ - ClientConnectionInfo, ClientDebugState, FileAccess, SessionInterruptQueues, SwarmEvent, + ClientConnectionInfo, ClientDebugState, FileTouchService, SessionInterruptQueues, SwarmEvent, SwarmMember, VersionedPlan, }; use crate::tool::Registry; @@ -17,7 +17,6 @@ use anyhow::Result; use async_trait::async_trait; use jcode_agent_runtime::InterruptSignal; use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tokio::sync::{Mutex, RwLock, broadcast, mpsc}; diff --git a/crates/jcode-app-core/src/server/client_session_tests/clear.rs b/crates/jcode-app-core/src/server/client_session_tests/clear.rs index 758515e19..9a7e6f47c 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/clear.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/clear.rs @@ -58,9 +58,7 @@ async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_regi )]))); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -90,8 +88,7 @@ async fn handle_clear_session_replaces_runtime_handles_and_updates_shutdown_regi &client_connections, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs index d04acd44e..f3dff29ce 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/attach_without_local_history.rs @@ -71,9 +71,7 @@ async fn handle_resume_session_allows_attach_without_local_history() -> Result<( let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -114,8 +112,7 @@ async fn handle_resume_session_allows_attach_without_local_history() -> Result<( &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs index fc5cb93ff..3a9d7ba71 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/busy_existing_attach.rs @@ -75,9 +75,7 @@ async fn handle_resume_session_allows_live_attach_when_existing_agent_is_busy() ]))); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -119,8 +117,7 @@ async fn handle_resume_session_allows_live_attach_when_existing_agent_is_busy() &Arc::new(RwLock::new(ClientDebugState::default())), &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs index 96040ce38..fcf6e34b1 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/different_client_attach.rs @@ -71,9 +71,7 @@ async fn handle_resume_session_allows_attach_from_different_client_instance() -> let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -114,8 +112,7 @@ async fn handle_resume_session_allows_attach_from_different_client_instance() -> &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs index 97558cbdd..c9bca36e2 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/live_events_before_history.rs @@ -66,9 +66,7 @@ async fn handle_resume_session_registers_live_events_before_history_replay() -> }, )]))); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -102,8 +100,7 @@ async fn handle_resume_session_registers_live_events_before_history_replay() -> let client_debug_state = Arc::clone(&client_debug_state); let swarm_members = Arc::clone(&swarm_members); let swarms_by_id = Arc::clone(&swarms_by_id); - let file_touches = Arc::clone(&file_touches); - let files_touched_by_session = Arc::clone(&files_touched_by_session); + let file_touch = file_touch.clone(); let channel_subscriptions = Arc::clone(&channel_subscriptions); let channel_subscriptions_by_session = Arc::clone(&channel_subscriptions_by_session); let swarm_plans = Arc::clone(&swarm_plans); @@ -135,8 +132,7 @@ async fn handle_resume_session_registers_live_events_before_history_replay() -> &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs index 4dd0edd5a..ab374dde6 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/multiple_live_attach.rs @@ -62,9 +62,7 @@ async fn handle_resume_session_allows_multiple_live_tui_attach() -> Result<()> { ]))); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -105,8 +103,7 @@ async fn handle_resume_session_allows_multiple_live_tui_attach() -> Result<()> { &Arc::new(RwLock::new(ClientDebugState::default())), &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs index 77aa96899..6acaa2a21 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/reconnect_takeover_with_history.rs @@ -71,9 +71,7 @@ async fn handle_resume_session_allows_reconnect_takeover_with_local_history() -> let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -114,8 +112,7 @@ async fn handle_resume_session_allows_reconnect_takeover_with_local_history() -> &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, diff --git a/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs b/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs index c044f0f48..c0444ce9b 100644 --- a/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs +++ b/crates/jcode-app-core/src/server/client_session_tests/resume/same_client_takeover.rs @@ -73,9 +73,7 @@ async fn handle_resume_session_allows_same_client_instance_takeover_without_loca let client_debug_state = Arc::new(RwLock::new(ClientDebugState::default())); let swarm_members = Arc::new(RwLock::new(HashMap::::new())); let swarms_by_id = Arc::new(RwLock::new(HashMap::>::new())); - let file_touches = Arc::new(RwLock::new(HashMap::>::new())); - let files_touched_by_session = - Arc::new(RwLock::new(HashMap::>::new())); + let file_touch = FileTouchService::new(); let channel_subscriptions = Arc::new(RwLock::new(HashMap::< String, HashMap>, @@ -116,8 +114,7 @@ async fn handle_resume_session_allows_same_client_instance_takeover_without_loca &client_debug_state, &swarm_members, &swarms_by_id, - &file_touches, - &files_touched_by_session, + &file_touch, &channel_subscriptions, &channel_subscriptions_by_session, &swarm_plans, From b228263dea5f145f53b33dcdfed4fe3287cf4020 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:23:10 -0700 Subject: [PATCH 09/41] refactor(tui): group token accounting fields into TokenAccounting --- crates/jcode-tui/src/tui/app.rs | 83 +++++++++++-------- crates/jcode-tui/src/tui/app/local.rs | 4 +- .../src/tui/app/remote/server_events.rs | 62 +++++++------- crates/jcode-tui/src/tui/app/state_ui.rs | 42 +++++----- crates/jcode-tui/src/tui/app/tests.rs | 12 +-- .../tests/remote_events_reload_01/part_01.rs | 4 +- .../tui/app/tests/remote_events_reload_04.rs | 20 ++--- crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 24 +----- crates/jcode-tui/src/tui/app/tui_state.rs | 28 +++---- 9 files changed, 136 insertions(+), 143 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 4dc8e889e..24825627a 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -544,6 +544,28 @@ struct CommandCandidatesCache { candidates: Vec<(String, &'static str)>, } +/// Session-wide token and cache accounting accumulated across all turns. +/// +/// Grouped out of [`App`] to keep the cohesive token/cache totals together. The +/// `total_*` fields accumulate over the whole session; the `last_*` fields hold +/// the most recently reported per-turn values used for cache TTL display. +#[derive(Clone, Debug, Default)] +struct TokenAccounting { + // Total session token usage (accumulated across all turns) + total_input_tokens: u64, + total_output_tokens: u64, + // Total session KV cache usage for turns where the provider reported cache telemetry. + total_cache_reported_input_tokens: u64, + total_cache_read_tokens: u64, + total_cache_creation_tokens: u64, + total_cache_optimal_input_tokens: u64, + last_cache_reported_input_tokens: Option, + last_cache_read_tokens: Option, + last_cache_creation_tokens: Option, + last_cache_optimal_input_tokens: Option, + cache_next_optimal_input_tokens: Option, +} + /// State for an in-progress OAuth/API-key login flow triggered by `/login`. /// TUI Application state pub struct App { @@ -583,19 +605,8 @@ pub struct App { connection_type: Option, // Provider-supplied human-readable transport detail for the current stream status_detail: Option, - // Total session token usage (accumulated across all turns) - total_input_tokens: u64, - total_output_tokens: u64, - // Total session KV cache usage for turns where the provider reported cache telemetry. - total_cache_reported_input_tokens: u64, - total_cache_read_tokens: u64, - total_cache_creation_tokens: u64, - total_cache_optimal_input_tokens: u64, - last_cache_reported_input_tokens: Option, - last_cache_read_tokens: Option, - last_cache_creation_tokens: Option, - last_cache_optimal_input_tokens: Option, - cache_next_optimal_input_tokens: Option, + // Session-wide token + cache accounting (accumulated across all turns). + token_accounting: TokenAccounting, kv_cache_baseline: Option, pending_kv_cache_request: Option, current_api_usage_recorded: bool, @@ -1361,12 +1372,12 @@ impl App { return false; } - let optimal_input_tokens = self.cache_next_optimal_input_tokens; + let optimal_input_tokens = self.token_accounting.cache_next_optimal_input_tokens; // Stash the *effective* prompt size for this request so the next request's // cache-read can be compared against everything that just became cacheable. // For split-accounting providers (Anthropic) bare `input` is only the // uncached remainder, so the reusable prefix is input + read + creation. - self.cache_next_optimal_input_tokens = + self.token_accounting.cache_next_optimal_input_tokens = Some(crate::tui::info_widget::effective_prompt_tokens( self.streaming_input_tokens, self.streaming_cache_read_tokens.unwrap_or(0), @@ -1396,24 +1407,24 @@ impl App { return true; } - self.total_cache_reported_input_tokens = self - .total_cache_reported_input_tokens + self.token_accounting.total_cache_reported_input_tokens = self + .token_accounting.total_cache_reported_input_tokens .saturating_add(self.streaming_input_tokens); if let Some(optimal) = optimal_input_tokens { - self.total_cache_optimal_input_tokens = self - .total_cache_optimal_input_tokens + self.token_accounting.total_cache_optimal_input_tokens = self + .token_accounting.total_cache_optimal_input_tokens .saturating_add(optimal); } - self.total_cache_read_tokens = self - .total_cache_read_tokens + self.token_accounting.total_cache_read_tokens = self + .token_accounting.total_cache_read_tokens .saturating_add(self.streaming_cache_read_tokens.unwrap_or(0)); - self.total_cache_creation_tokens = self - .total_cache_creation_tokens + self.token_accounting.total_cache_creation_tokens = self + .token_accounting.total_cache_creation_tokens .saturating_add(self.streaming_cache_creation_tokens.unwrap_or(0)); - self.last_cache_reported_input_tokens = Some(self.streaming_input_tokens); - self.last_cache_read_tokens = Some(self.streaming_cache_read_tokens.unwrap_or(0)); - self.last_cache_creation_tokens = Some(self.streaming_cache_creation_tokens.unwrap_or(0)); - self.last_cache_optimal_input_tokens = optimal_input_tokens; + self.token_accounting.last_cache_reported_input_tokens = Some(self.streaming_input_tokens); + self.token_accounting.last_cache_read_tokens = Some(self.streaming_cache_read_tokens.unwrap_or(0)); + self.token_accounting.last_cache_creation_tokens = Some(self.streaming_cache_creation_tokens.unwrap_or(0)); + self.token_accounting.last_cache_optimal_input_tokens = optimal_input_tokens; self.log_kv_cache_usage_summary(&request, optimal_input_tokens); @@ -1441,13 +1452,13 @@ impl App { let creation_pct = ratio_pct(creation_tokens, input_tokens); let optimal_read_pct = optimal_input_tokens.map(|optimal| ratio_pct(read_tokens, optimal)); let session_read_pct = ratio_pct( - self.total_cache_read_tokens, - self.total_cache_reported_input_tokens, + self.token_accounting.total_cache_read_tokens, + self.token_accounting.total_cache_reported_input_tokens, ); - let session_optimal_read_pct = if self.total_cache_optimal_input_tokens > 0 { + let session_optimal_read_pct = if self.token_accounting.total_cache_optimal_input_tokens > 0 { Some(ratio_pct( - self.total_cache_read_tokens, - self.total_cache_optimal_input_tokens, + self.token_accounting.total_cache_read_tokens, + self.token_accounting.total_cache_optimal_input_tokens, )) } else { None @@ -1571,11 +1582,11 @@ impl App { optimal_read_pct, missed_tokens, miss, - self.total_cache_reported_input_tokens, - self.total_cache_read_tokens, - self.total_cache_creation_tokens, + self.token_accounting.total_cache_reported_input_tokens, + self.token_accounting.total_cache_read_tokens, + self.token_accounting.total_cache_creation_tokens, session_read_pct, - self.total_cache_optimal_input_tokens, + self.token_accounting.total_cache_optimal_input_tokens, session_optimal_read_pct, baseline_input_tokens, baseline_age_secs, diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index b98883f7a..264c62ed5 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -459,8 +459,8 @@ fn handle_input_shell_completed(app: &mut App, shell: InputShellCompleted) { } pub(super) fn finish_turn(app: &mut App) { - app.total_input_tokens += app.streaming_input_tokens; - app.total_output_tokens += app.streaming_output_tokens; + app.token_accounting.total_input_tokens += app.streaming_input_tokens; + app.token_accounting.total_output_tokens += app.streaming_output_tokens; app.update_cost_impl(); app.is_processing = false; app.status = ProcessingStatus::Idle; diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index 9d8b4d62a..060e528ca 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -427,8 +427,10 @@ pub(in crate::tui::app) fn handle_server_event( app.streaming_cache_creation_tokens = cache_creation_input; } if app.record_completed_stream_cache_usage() { - app.total_input_tokens = app.total_input_tokens.saturating_add(input); - app.total_output_tokens = app.total_output_tokens.saturating_add(output); + app.token_accounting.total_input_tokens = + app.token_accounting.total_input_tokens.saturating_add(input); + app.token_accounting.total_output_tokens = + app.token_accounting.total_output_tokens.saturating_add(output); // The server only reports tokens, never a dollar cost, so the // remote client prices each completed call itself. This is the // first usage snapshot for this call, so bill the full counts. @@ -443,11 +445,11 @@ pub(in crate::tui::app) fn handle_server_event( app.last_api_completed_model = Some(::provider_model(app)); app.last_turn_input_tokens = (input > 0).then_some(input); } else if was_recorded && app.current_api_usage_recorded { - app.total_input_tokens = app - .total_input_tokens + app.token_accounting.total_input_tokens = app + .token_accounting.total_input_tokens .saturating_add(input.saturating_sub(previous_input)); - app.total_output_tokens = app - .total_output_tokens + app.token_accounting.total_output_tokens = app + .token_accounting.total_output_tokens .saturating_add(output.saturating_sub(previous_output)); // Bill only the new tokens since the previous snapshot for this // same call, so a call that reports usage multiple times while @@ -473,23 +475,23 @@ pub(in crate::tui::app) fn handle_server_event( } else { input }; - app.total_cache_reported_input_tokens = app - .total_cache_reported_input_tokens + app.token_accounting.total_cache_reported_input_tokens = app + .token_accounting.total_cache_reported_input_tokens .saturating_add(reported_delta); - app.total_cache_read_tokens = app.total_cache_read_tokens.saturating_add( + app.token_accounting.total_cache_read_tokens = app.token_accounting.total_cache_read_tokens.saturating_add( app.streaming_cache_read_tokens .unwrap_or(0) .saturating_sub(previous_cache_read.unwrap_or(0)), ); - app.total_cache_creation_tokens = - app.total_cache_creation_tokens.saturating_add( + app.token_accounting.total_cache_creation_tokens = + app.token_accounting.total_cache_creation_tokens.saturating_add( app.streaming_cache_creation_tokens .unwrap_or(0) .saturating_sub(previous_cache_creation.unwrap_or(0)), ); - app.last_cache_reported_input_tokens = Some(input); - app.last_cache_read_tokens = Some(app.streaming_cache_read_tokens.unwrap_or(0)); - app.last_cache_creation_tokens = + app.token_accounting.last_cache_reported_input_tokens = Some(input); + app.token_accounting.last_cache_read_tokens = Some(app.streaming_cache_read_tokens.unwrap_or(0)); + app.token_accounting.last_cache_creation_tokens = Some(app.streaming_cache_creation_tokens.unwrap_or(0)); } @@ -497,7 +499,7 @@ pub(in crate::tui::app) fn handle_server_event( baseline.input_tokens = input; baseline.completed_at = Instant::now(); } - app.cache_next_optimal_input_tokens = + app.token_accounting.cache_next_optimal_input_tokens = Some(crate::tui::info_widget::effective_prompt_tokens( input, app.streaming_cache_read_tokens.unwrap_or(0), @@ -1054,15 +1056,15 @@ pub(in crate::tui::app) fn handle_server_event( app.streaming_cache_read_tokens = None; app.streaming_cache_creation_tokens = None; app.current_api_usage_recorded = false; - app.total_cache_reported_input_tokens = 0; - app.total_cache_read_tokens = 0; - app.total_cache_creation_tokens = 0; - app.total_cache_optimal_input_tokens = 0; - app.last_cache_reported_input_tokens = None; - app.last_cache_read_tokens = None; - app.last_cache_creation_tokens = None; - app.last_cache_optimal_input_tokens = None; - app.cache_next_optimal_input_tokens = None; + app.token_accounting.total_cache_reported_input_tokens = 0; + app.token_accounting.total_cache_read_tokens = 0; + app.token_accounting.total_cache_creation_tokens = 0; + app.token_accounting.total_cache_optimal_input_tokens = 0; + app.token_accounting.last_cache_reported_input_tokens = None; + app.token_accounting.last_cache_read_tokens = None; + app.token_accounting.last_cache_creation_tokens = None; + app.token_accounting.last_cache_optimal_input_tokens = None; + app.token_accounting.cache_next_optimal_input_tokens = None; app.kv_cache_baseline = None; app.pending_kv_cache_request = None; app.kv_cache_turn_number = None; @@ -1145,12 +1147,12 @@ pub(in crate::tui::app) fn handle_server_event( app.remote_token_usage_totals = token_usage_totals; } if token_usage_totals.is_some() { - app.total_input_tokens = 0; - app.total_output_tokens = 0; - app.total_cache_reported_input_tokens = 0; - app.total_cache_read_tokens = 0; - app.total_cache_creation_tokens = 0; - app.total_cache_optimal_input_tokens = 0; + app.token_accounting.total_input_tokens = 0; + app.token_accounting.total_output_tokens = 0; + app.token_accounting.total_cache_reported_input_tokens = 0; + app.token_accounting.total_cache_read_tokens = 0; + app.token_accounting.total_cache_creation_tokens = 0; + app.token_accounting.total_cache_optimal_input_tokens = 0; } if let Some(totals) = token_usage_totals { crate::logging::info(&format!( diff --git a/crates/jcode-tui/src/tui/app/state_ui.rs b/crates/jcode-tui/src/tui/app/state_ui.rs index e2edf6817..9c1d6d205 100644 --- a/crates/jcode-tui/src/tui/app/state_ui.rs +++ b/crates/jcode-tui/src/tui/app/state_ui.rs @@ -984,10 +984,10 @@ fn format_cache_stats(app: &App) -> String { let remote_cache_write = remote_usage .map(|usage| usage.cache_creation_input_tokens) .unwrap_or(0); - let reported = remote_cache_reported.saturating_add(app.total_cache_reported_input_tokens); - let read = remote_cache_read.saturating_add(app.total_cache_read_tokens); - let write = remote_cache_write.saturating_add(app.total_cache_creation_tokens); - let optimal = app.total_cache_optimal_input_tokens; + let reported = remote_cache_reported.saturating_add(app.token_accounting.total_cache_reported_input_tokens); + let read = remote_cache_read.saturating_add(app.token_accounting.total_cache_read_tokens); + let write = remote_cache_write.saturating_add(app.token_accounting.total_cache_creation_tokens); + let optimal = app.token_accounting.total_cache_optimal_input_tokens; // `reported` is the aggregate of provider-reported `input_tokens`, which for // split-accounting providers (Anthropic) excludes cached + cache-creation // tokens. Percentages must use the effective prompt size so they stay in @@ -999,7 +999,7 @@ fn format_cache_stats(app: &App) -> String { let optimal_pct = (optimal > 0).then(|| cache_ratio_pct(read, optimal)); let cache_totals_source = match ( remote_usage.is_some(), - app.total_cache_reported_input_tokens > 0, + app.token_accounting.total_cache_reported_input_tokens > 0, ) { (true, true) => "remote_history+client_observed_api_calls", (true, false) => "remote_history", @@ -1130,9 +1130,9 @@ fn format_cache_stats(app: &App) -> String { let (history_input_tokens, history_output_tokens, totals_source) = if app.is_remote { if let Some((input, output)) = remote_history_tokens { ( - input.saturating_add(app.total_input_tokens), - output.saturating_add(app.total_output_tokens), - if app.total_input_tokens > 0 || app.total_output_tokens > 0 { + input.saturating_add(app.token_accounting.total_input_tokens), + output.saturating_add(app.token_accounting.total_output_tokens), + if app.token_accounting.total_input_tokens > 0 || app.token_accounting.total_output_tokens > 0 { "remote_history+client_observed_api_calls" } else { "remote_history" @@ -1140,15 +1140,15 @@ fn format_cache_stats(app: &App) -> String { ) } else { ( - app.total_input_tokens, - app.total_output_tokens, + app.token_accounting.total_input_tokens, + app.token_accounting.total_output_tokens, "client_observed_api_calls", ) } } else { ( - app.total_input_tokens, - app.total_output_tokens, + app.token_accounting.total_input_tokens, + app.token_accounting.total_output_tokens, "local_completed_turns", ) }; @@ -1211,11 +1211,11 @@ fn format_cache_stats(app: &App) -> String { )); lines.push(format!( "- client_observed_completed_input_tokens: {}", - bold_count(app.total_input_tokens) + bold_count(app.token_accounting.total_input_tokens) )); lines.push(format!( "- client_observed_completed_output_tokens: {}", - bold_count(app.total_output_tokens) + bold_count(app.token_accounting.total_output_tokens) )); lines.push(format!("- total_cost_usd: {:.6}", app.total_cost)); lines.push(format!( @@ -1309,23 +1309,23 @@ fn format_cache_stats(app: &App) -> String { )); lines.push(format!( "- last_cache_reported_input_tokens: {}", - opt_u64(app.last_cache_reported_input_tokens) + opt_u64(app.token_accounting.last_cache_reported_input_tokens) )); lines.push(format!( "- last_cache_read_tokens: {}", - opt_u64(app.last_cache_read_tokens) + opt_u64(app.token_accounting.last_cache_read_tokens) )); lines.push(format!( "- last_cache_creation_tokens: {}", - opt_u64(app.last_cache_creation_tokens) + opt_u64(app.token_accounting.last_cache_creation_tokens) )); lines.push(format!( "- last_cache_optimal_input_tokens: {}", - opt_u64(app.last_cache_optimal_input_tokens) + opt_u64(app.token_accounting.last_cache_optimal_input_tokens) )); lines.push(format!( "- cache_next_optimal_input_tokens: {}", - opt_u64(app.cache_next_optimal_input_tokens) + opt_u64(app.token_accounting.cache_next_optimal_input_tokens) )); lines.push(String::new()); @@ -1738,7 +1738,7 @@ pub(super) fn handle_info_command(app: &mut App, trimmed: &str) -> bool { )); info.push_str(&format!( "Tokens: ↑{} ↓{}\n", - app.total_input_tokens, app.total_output_tokens + app.token_accounting.total_input_tokens, app.token_accounting.total_output_tokens )); info.push_str(&format!("Terminal: {}\n", terminal_size)); info.push_str(&format!("CWD: {}\n", cwd)); @@ -1818,7 +1818,7 @@ pub(super) fn handle_info_command(app: &mut App, trimmed: &str) -> bool { app.provider.reasoning_effort(), app.provider.service_tier(), app.provider.transport(), - Some((app.total_input_tokens, app.total_output_tokens)), + Some((app.token_accounting.total_input_tokens, app.token_accounting.total_output_tokens)), ) }; diff --git a/crates/jcode-tui/src/tui/app/tests.rs b/crates/jcode-tui/src/tui/app/tests.rs index b07dc8523..96a403f3e 100644 --- a/crates/jcode-tui/src/tui/app/tests.rs +++ b/crates/jcode-tui/src/tui/app/tests.rs @@ -307,10 +307,10 @@ fn remote_token_usage_records_cache_stats_before_done_and_dedupes_snapshots() { &mut remote, ); - assert_eq!(app.total_cache_reported_input_tokens, 63_762); - assert_eq!(app.total_cache_read_tokens, 0); - assert_eq!(app.last_cache_reported_input_tokens, Some(63_762)); - assert_eq!(app.total_input_tokens, 63_762); + assert_eq!(app.token_accounting.total_cache_reported_input_tokens, 63_762); + assert_eq!(app.token_accounting.total_cache_read_tokens, 0); + assert_eq!(app.token_accounting.last_cache_reported_input_tokens, Some(63_762)); + assert_eq!(app.token_accounting.total_input_tokens, 63_762); assert!(app.last_api_completed.is_some()); assert!(app.pending_kv_cache_request.is_none()); @@ -324,8 +324,8 @@ fn remote_token_usage_records_cache_stats_before_done_and_dedupes_snapshots() { &mut remote, ); - assert_eq!(app.total_cache_reported_input_tokens, 63_762); - assert_eq!(app.total_input_tokens, 63_762); + assert_eq!(app.token_accounting.total_cache_reported_input_tokens, 63_762); + assert_eq!(app.token_accounting.total_input_tokens, 63_762); assert!(super::state_ui::handle_info_command( &mut app, diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs index a398b3962..419b638ce 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs @@ -572,8 +572,8 @@ fn test_handle_server_event_token_usage_uses_per_call_deltas() { assert_eq!(app.streaming_output_tokens, 30); assert_eq!(app.streaming_total_output_tokens, 30); - assert_eq!(app.total_input_tokens, 100); - assert_eq!(app.total_output_tokens, 30); + assert_eq!(app.token_accounting.total_input_tokens, 100); + assert_eq!(app.token_accounting.total_output_tokens, 30); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs index 8b8b8fab0..812ce749b 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs @@ -645,8 +645,8 @@ fn test_info_widget_remote_opencode_shows_cost_based_usage() { app.is_remote = true; app.remote_provider_name = Some("opencode".to_string()); app.remote_provider_model = Some("qwen3-coder".to_string()); - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; let data = crate::tui::TuiState::info_widget_data(&app); @@ -673,8 +673,8 @@ fn test_info_widget_remote_anthropic_api_key_shows_cost_based_usage() { app.remote_provider_name = Some("Claude".to_string()); app.remote_provider_model = Some("claude-sonnet-4-20250514".to_string()); app.remote_resolved_credential = Some(jcode_provider_core::ResolvedCredential::ApiKey); - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; let data = crate::tui::TuiState::info_widget_data(&app); assert_eq!( @@ -775,8 +775,8 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { let mut app = create_named_provider_test_app(provider_name, model); app.streaming_input_tokens = 1_000; app.streaming_output_tokens = 1_000; - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); assert!( @@ -804,8 +804,8 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { let mut app = create_named_provider_test_app("openrouter", "subscription-model"); app.streaming_input_tokens = 1_000; app.streaming_output_tokens = 1_000; - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); assert_eq!(app.total_cost, 0.0); @@ -821,8 +821,8 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { let mut app = create_named_provider_test_app("openrouter", "local-model"); app.streaming_input_tokens = 1_000; app.streaming_output_tokens = 1_000; - app.total_input_tokens = 12_000; - app.total_output_tokens = 3_400; + app.token_accounting.total_input_tokens = 12_000; + app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); assert_eq!(app.total_cost, 0.0); diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 197195d48..7f14e89c8 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -306,17 +306,7 @@ impl App { upstream_provider: None, connection_type: None, status_detail: None, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_reported_input_tokens: 0, - total_cache_read_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_optimal_input_tokens: 0, - last_cache_reported_input_tokens: None, - last_cache_read_tokens: None, - last_cache_creation_tokens: None, - last_cache_optimal_input_tokens: None, - cache_next_optimal_input_tokens: None, + token_accounting: TokenAccounting::default(), kv_cache_baseline: None, pending_kv_cache_request: None, current_api_usage_recorded: false, @@ -707,17 +697,7 @@ impl App { upstream_provider: None, connection_type: None, status_detail: None, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_reported_input_tokens: 0, - total_cache_read_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_optimal_input_tokens: 0, - last_cache_reported_input_tokens: None, - last_cache_read_tokens: None, - last_cache_creation_tokens: None, - last_cache_optimal_input_tokens: None, - cache_next_optimal_input_tokens: None, + token_accounting: TokenAccounting::default(), kv_cache_baseline: None, pending_kv_cache_request: None, current_api_usage_recorded: false, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index a8e5b4e66..b828a137b 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -263,8 +263,8 @@ impl App { spark: None, spark_resets_at: None, total_cost: self.total_cost, - input_tokens: self.total_input_tokens, - output_tokens: self.total_output_tokens, + input_tokens: self.token_accounting.total_input_tokens, + output_tokens: self.token_accounting.total_output_tokens, cache_read_tokens: self.streaming_cache_read_tokens, cache_write_tokens: self.streaming_cache_creation_tokens, output_tps, @@ -281,12 +281,12 @@ impl App { spark: None, spark_resets_at: None, total_cost: 0.0, - input_tokens: self.total_input_tokens, - output_tokens: self.total_output_tokens, + input_tokens: self.token_accounting.total_input_tokens, + output_tokens: self.token_accounting.total_output_tokens, cache_read_tokens: None, cache_write_tokens: None, output_tps, - available: self.total_input_tokens > 0 || self.total_output_tokens > 0, + available: self.token_accounting.total_input_tokens > 0 || self.token_accounting.total_output_tokens > 0, }), WidgetProviderKind::Anthropic => { if matches!( @@ -1199,16 +1199,16 @@ impl crate::tui::TuiState for App { None }; - let cache_hit_info = (self.total_cache_reported_input_tokens > 0).then(|| { + let cache_hit_info = (self.token_accounting.total_cache_reported_input_tokens > 0).then(|| { crate::tui::info_widget::CacheHitInfo { - reported_input_tokens: self.total_cache_reported_input_tokens, - read_tokens: self.total_cache_read_tokens, - creation_tokens: self.total_cache_creation_tokens, - optimal_input_tokens: self.total_cache_optimal_input_tokens, - last_reported_input_tokens: self.last_cache_reported_input_tokens, - last_read_tokens: self.last_cache_read_tokens, - last_creation_tokens: self.last_cache_creation_tokens, - last_optimal_input_tokens: self.last_cache_optimal_input_tokens, + reported_input_tokens: self.token_accounting.total_cache_reported_input_tokens, + read_tokens: self.token_accounting.total_cache_read_tokens, + creation_tokens: self.token_accounting.total_cache_creation_tokens, + optimal_input_tokens: self.token_accounting.total_cache_optimal_input_tokens, + last_reported_input_tokens: self.token_accounting.last_cache_reported_input_tokens, + last_read_tokens: self.token_accounting.last_cache_read_tokens, + last_creation_tokens: self.token_accounting.last_cache_creation_tokens, + last_optimal_input_tokens: self.token_accounting.last_cache_optimal_input_tokens, miss_attributions: self .kv_cache_miss_samples .iter() From abe7b3165037e7a5ec4529158ff35bd60fae40ca Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:24:16 -0700 Subject: [PATCH 10/41] refactor(tui): group KV cache fields into KvCacheState --- crates/jcode-tui/src/tui/app.rs | 77 +++++++++++-------- crates/jcode-tui/src/tui/app/commands.rs | 2 +- .../jcode-tui/src/tui/app/commands_improve.rs | 2 +- .../src/tui/app/commands_overnight.rs | 2 +- crates/jcode-tui/src/tui/app/input.rs | 4 +- crates/jcode-tui/src/tui/app/model_context.rs | 2 +- .../src/tui/app/remote/server_events.rs | 18 ++--- crates/jcode-tui/src/tui/app/state_ui.rs | 22 +++--- crates/jcode-tui/src/tui/app/tests.rs | 12 +-- crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 14 +--- crates/jcode-tui/src/tui/app/tui_state.rs | 2 +- 11 files changed, 79 insertions(+), 78 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 24825627a..f2aa540bf 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -566,6 +566,21 @@ struct TokenAccounting { cache_next_optimal_input_tokens: Option, } +/// KV cache baseline tracking and per-turn cache-miss attribution. +/// +/// Grouped out of [`App`]. The baseline and pending-request fields drive cache +/// telemetry recording; the turn/call indices and miss samples feed the cache +/// hit/miss attribution surfaced in the info widget. +#[derive(Clone, Debug, Default)] +struct KvCacheState { + kv_cache_baseline: Option, + pending_kv_cache_request: Option, + current_api_usage_recorded: bool, + kv_cache_turn_number: Option, + kv_cache_turn_call_index: u16, + kv_cache_miss_samples: Vec, +} + /// State for an in-progress OAuth/API-key login flow triggered by `/login`. /// TUI Application state pub struct App { @@ -607,12 +622,8 @@ pub struct App { status_detail: Option, // Session-wide token + cache accounting (accumulated across all turns). token_accounting: TokenAccounting, - kv_cache_baseline: Option, - pending_kv_cache_request: Option, - current_api_usage_recorded: bool, - kv_cache_turn_number: Option, - kv_cache_turn_call_index: u16, - kv_cache_miss_samples: Vec, + // KV cache baseline tracking + per-turn miss attribution. + kv_cache: KvCacheState, // Total cost in USD (for API-key providers) total_cost: f32, // Cached pricing (input $/1M tokens, output $/1M tokens) @@ -1220,11 +1231,11 @@ impl App { .filter(|message| message.role == "user") .count() .max(1); - if self.kv_cache_turn_number == Some(turn_number) { - self.kv_cache_turn_call_index = self.kv_cache_turn_call_index.saturating_add(1).max(1); + if self.kv_cache.kv_cache_turn_number == Some(turn_number) { + self.kv_cache.kv_cache_turn_call_index = self.kv_cache.kv_cache_turn_call_index.saturating_add(1).max(1); } else { - self.kv_cache_turn_number = Some(turn_number); - self.kv_cache_turn_call_index = 1; + self.kv_cache.kv_cache_turn_number = Some(turn_number); + self.kv_cache.kv_cache_turn_call_index = 1; } let baseline = self.kv_cache_baseline_for_current_session(); @@ -1237,15 +1248,15 @@ impl App { self.maybe_push_cold_cache_warning( turn_number, - self.kv_cache_turn_call_index, + self.kv_cache.kv_cache_turn_call_index, baseline.as_ref(), ); self.pause_streaming_tps(false); - self.current_api_usage_recorded = false; + self.kv_cache.current_api_usage_recorded = false; - self.pending_kv_cache_request = Some(PendingKvCacheRequest { + self.kv_cache.pending_kv_cache_request = Some(PendingKvCacheRequest { turn_number, - call_index: self.kv_cache_turn_call_index, + call_index: self.kv_cache.kv_cache_turn_call_index, provider: self.kv_cache_provider_name(), model: self.kv_cache_provider_model(), upstream_provider: self.upstream_provider.clone(), @@ -1265,11 +1276,11 @@ impl App { .filter(|message| message.role == "user") .count() .max(1); - if self.kv_cache_turn_number == Some(turn_number) { - self.kv_cache_turn_call_index = self.kv_cache_turn_call_index.saturating_add(1).max(1); + if self.kv_cache.kv_cache_turn_number == Some(turn_number) { + self.kv_cache.kv_cache_turn_call_index = self.kv_cache.kv_cache_turn_call_index.saturating_add(1).max(1); } else { - self.kv_cache_turn_number = Some(turn_number); - self.kv_cache_turn_call_index = 1; + self.kv_cache.kv_cache_turn_number = Some(turn_number); + self.kv_cache.kv_cache_turn_call_index = 1; } let baseline = self.kv_cache_baseline_for_current_session(); @@ -1279,14 +1290,14 @@ impl App { .map(|previous| Self::kv_cache_signatures_prefix_match(&signature, previous)); self.maybe_push_cold_cache_warning( turn_number, - self.kv_cache_turn_call_index, + self.kv_cache.kv_cache_turn_call_index, baseline.as_ref(), ); self.pause_streaming_tps(false); - self.current_api_usage_recorded = false; - self.pending_kv_cache_request = Some(PendingKvCacheRequest { + self.kv_cache.current_api_usage_recorded = false; + self.kv_cache.pending_kv_cache_request = Some(PendingKvCacheRequest { turn_number, - call_index: self.kv_cache_turn_call_index, + call_index: self.kv_cache.kv_cache_turn_call_index, provider: self.kv_cache_provider_name(), model: self.kv_cache_provider_model(), upstream_provider: self.upstream_provider.clone(), @@ -1316,7 +1327,7 @@ impl App { /// emits a spurious `harness:_prefix_changed` miss. Treat a foreign baseline /// as absent (warmup) instead. fn kv_cache_baseline_for_current_session(&self) -> Option { - let baseline = self.kv_cache_baseline.clone()?; + let baseline = self.kv_cache.kv_cache_baseline.clone()?; let current = self.kv_cache_session_id(); if baseline.session_id == current { Some(baseline) @@ -1365,7 +1376,7 @@ impl App { pub(super) fn record_completed_stream_cache_usage(&mut self) -> bool { let has_cache_telemetry = self.streaming_cache_read_tokens.is_some() || self.streaming_cache_creation_tokens.is_some(); - if self.current_api_usage_recorded { + if self.kv_cache.current_api_usage_recorded { return false; } if self.streaming_input_tokens == 0 { @@ -1385,17 +1396,17 @@ impl App { )); let request = self - .pending_kv_cache_request + .kv_cache.pending_kv_cache_request .take() .unwrap_or_else(|| self.fallback_pending_kv_cache_request()); - self.current_api_usage_recorded = true; + self.kv_cache.current_api_usage_recorded = true; self.record_kv_cache_miss_sample(&request); let baseline_session_id = self.kv_cache_session_id(); if !has_cache_telemetry { - self.kv_cache_baseline = Some(KvCacheBaseline { + self.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: baseline_session_id, input_tokens: self.streaming_input_tokens, completed_at: Instant::now(), @@ -1428,7 +1439,7 @@ impl App { self.log_kv_cache_usage_summary(&request, optimal_input_tokens); - self.kv_cache_baseline = Some(KvCacheBaseline { + self.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: baseline_session_id, input_tokens: self.streaming_input_tokens, completed_at: Instant::now(), @@ -1464,7 +1475,7 @@ impl App { None }; let miss = self - .kv_cache_miss_samples + .kv_cache.kv_cache_miss_samples .last() .filter(|sample| { sample.turn_number == request.turn_number && sample.call_index == request.call_index @@ -1672,15 +1683,15 @@ impl App { return; } - self.kv_cache_miss_samples.push(KvCacheMissSample { + self.kv_cache.kv_cache_miss_samples.push(KvCacheMissSample { turn_number: request.turn_number, call_index: request.call_index, missed_tokens, reason, }); - if self.kv_cache_miss_samples.len() > Self::KV_CACHE_MAX_MISS_SAMPLES { - let overflow = self.kv_cache_miss_samples.len() - Self::KV_CACHE_MAX_MISS_SAMPLES; - self.kv_cache_miss_samples.drain(0..overflow); + if self.kv_cache.kv_cache_miss_samples.len() > Self::KV_CACHE_MAX_MISS_SAMPLES { + let overflow = self.kv_cache.kv_cache_miss_samples.len() - Self::KV_CACHE_MAX_MISS_SAMPLES; + self.kv_cache.kv_cache_miss_samples.drain(0..overflow); } } diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 5b8f5a18b..210aa9fd5 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -306,7 +306,7 @@ pub(super) fn activate_auto_poke_local(app: &mut App) { app.streaming_output_tokens = 0; app.streaming_cache_read_tokens = None; app.streaming_cache_creation_tokens = None; - app.current_api_usage_recorded = false; + app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; app.streaming_tps_start = None; diff --git a/crates/jcode-tui/src/tui/app/commands_improve.rs b/crates/jcode-tui/src/tui/app/commands_improve.rs index 26e1cc6b4..cba26c9b2 100644 --- a/crates/jcode-tui/src/tui/app/commands_improve.rs +++ b/crates/jcode-tui/src/tui/app/commands_improve.rs @@ -423,7 +423,7 @@ pub(super) fn start_synthetic_user_turn(app: &mut App, content: String) { app.streaming_output_tokens = 0; app.streaming_cache_read_tokens = None; app.streaming_cache_creation_tokens = None; - app.current_api_usage_recorded = false; + app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; app.streaming_tps_start = None; diff --git a/crates/jcode-tui/src/tui/app/commands_overnight.rs b/crates/jcode-tui/src/tui/app/commands_overnight.rs index 0a3f30990..986fa19b8 100644 --- a/crates/jcode-tui/src/tui/app/commands_overnight.rs +++ b/crates/jcode-tui/src/tui/app/commands_overnight.rs @@ -101,7 +101,7 @@ fn start_visible_overnight_turn(app: &mut App, content: String) { app.streaming_output_tokens = 0; app.streaming_cache_read_tokens = None; app.streaming_cache_creation_tokens = None; - app.current_api_usage_recorded = false; + app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; app.streaming_tps_start = None; diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index cee4e3f14..8d3749964 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -2824,7 +2824,7 @@ impl App { self.streaming_output_tokens = 0; self.streaming_cache_read_tokens = None; self.streaming_cache_creation_tokens = None; - self.current_api_usage_recorded = false; + self.kv_cache.current_api_usage_recorded = false; self.upstream_provider = None; self.status_detail = None; self.streaming_tps_start = None; @@ -2892,7 +2892,7 @@ impl App { self.streaming_output_tokens = 0; self.streaming_cache_read_tokens = None; self.streaming_cache_creation_tokens = None; - self.current_api_usage_recorded = false; + self.kv_cache.current_api_usage_recorded = false; self.upstream_provider = None; self.status_detail = None; self.streaming_tps_start = None; diff --git a/crates/jcode-tui/src/tui/app/model_context.rs b/crates/jcode-tui/src/tui/app/model_context.rs index 6c242c18f..0e19a3e34 100644 --- a/crates/jcode-tui/src/tui/app/model_context.rs +++ b/crates/jcode-tui/src/tui/app/model_context.rs @@ -469,7 +469,7 @@ impl App { self.streaming_output_tokens = 0; self.streaming_cache_read_tokens = None; self.streaming_cache_creation_tokens = None; - self.current_api_usage_recorded = false; + self.kv_cache.current_api_usage_recorded = false; self.thought_line_inserted = false; self.thinking_prefix_emitted = false; self.thinking_buffer.clear(); diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index 060e528ca..1f25571f3 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -416,7 +416,7 @@ pub(in crate::tui::app) fn handle_server_event( let previous_output = app.streaming_output_tokens; let previous_cache_read = app.streaming_cache_read_tokens; let previous_cache_creation = app.streaming_cache_creation_tokens; - let was_recorded = app.current_api_usage_recorded; + let was_recorded = app.kv_cache.current_api_usage_recorded; app.accumulate_streaming_output_tokens(output, call_output_tokens_seen); app.streaming_input_tokens = input; app.streaming_output_tokens = output; @@ -444,7 +444,7 @@ pub(in crate::tui::app) fn handle_server_event( app.last_api_completed_provider = Some(::provider_name(app)); app.last_api_completed_model = Some(::provider_model(app)); app.last_turn_input_tokens = (input > 0).then_some(input); - } else if was_recorded && app.current_api_usage_recorded { + } else if was_recorded && app.kv_cache.current_api_usage_recorded { app.token_accounting.total_input_tokens = app .token_accounting.total_input_tokens .saturating_add(input.saturating_sub(previous_input)); @@ -495,7 +495,7 @@ pub(in crate::tui::app) fn handle_server_event( Some(app.streaming_cache_creation_tokens.unwrap_or(0)); } - if let Some(baseline) = app.kv_cache_baseline.as_mut() { + if let Some(baseline) = app.kv_cache.kv_cache_baseline.as_mut() { baseline.input_tokens = input; baseline.completed_at = Instant::now(); } @@ -1055,7 +1055,7 @@ pub(in crate::tui::app) fn handle_server_event( app.streaming_output_tokens = 0; app.streaming_cache_read_tokens = None; app.streaming_cache_creation_tokens = None; - app.current_api_usage_recorded = false; + app.kv_cache.current_api_usage_recorded = false; app.token_accounting.total_cache_reported_input_tokens = 0; app.token_accounting.total_cache_read_tokens = 0; app.token_accounting.total_cache_creation_tokens = 0; @@ -1065,11 +1065,11 @@ pub(in crate::tui::app) fn handle_server_event( app.token_accounting.last_cache_creation_tokens = None; app.token_accounting.last_cache_optimal_input_tokens = None; app.token_accounting.cache_next_optimal_input_tokens = None; - app.kv_cache_baseline = None; - app.pending_kv_cache_request = None; - app.kv_cache_turn_number = None; - app.kv_cache_turn_call_index = 0; - app.kv_cache_miss_samples.clear(); + app.kv_cache.kv_cache_baseline = None; + app.kv_cache.pending_kv_cache_request = None; + app.kv_cache.kv_cache_turn_number = None; + app.kv_cache.kv_cache_turn_call_index = 0; + app.kv_cache.kv_cache_miss_samples.clear(); app.processing_started = None; app.clear_visible_turn_started(); app.replay_processing_started_ms = None; diff --git a/crates/jcode-tui/src/tui/app/state_ui.rs b/crates/jcode-tui/src/tui/app/state_ui.rs index 9c1d6d205..27967d5cd 100644 --- a/crates/jcode-tui/src/tui/app/state_ui.rs +++ b/crates/jcode-tui/src/tui/app/state_ui.rs @@ -1007,7 +1007,7 @@ fn format_cache_stats(app: &App) -> String { (false, false) => "none_yet", }; let live_cache_telemetry = app.streaming_input_tokens > 0 - && !app.current_api_usage_recorded + && !app.kv_cache.current_api_usage_recorded && (app.streaming_cache_read_tokens.is_some() || app.streaming_cache_creation_tokens.is_some()); let live_reported = if live_cache_telemetry { @@ -1153,13 +1153,13 @@ fn format_cache_stats(app: &App) -> String { ) }; let live_unrecorded_input_tokens = - if app.streaming_input_tokens > 0 && !app.current_api_usage_recorded { + if app.streaming_input_tokens > 0 && !app.kv_cache.current_api_usage_recorded { app.streaming_input_tokens } else { 0 }; let live_unrecorded_output_tokens = - if app.streaming_output_tokens > 0 && !app.current_api_usage_recorded { + if app.streaming_output_tokens > 0 && !app.kv_cache.current_api_usage_recorded { app.streaming_output_tokens } else { 0 @@ -1352,7 +1352,7 @@ fn format_cache_stats(app: &App) -> String { )); lines.push(format!( "- current_api_usage_recorded: {}", - app.current_api_usage_recorded + app.kv_cache.current_api_usage_recorded )); lines.push(format!("- status: {:?}", app.status)); lines.push(format!("- is_processing: {}", app.is_processing)); @@ -1381,18 +1381,18 @@ fn format_cache_stats(app: &App) -> String { lines.push("KV cache tracker state".to_string()); lines.push(format!( "- kv_cache_turn_number: {}", - opt_usize(app.kv_cache_turn_number) + opt_usize(app.kv_cache.kv_cache_turn_number) )); lines.push(format!( "- kv_cache_turn_call_index: {}", - app.kv_cache_turn_call_index + app.kv_cache.kv_cache_turn_call_index )); lines.push(format!( "- kv_cache_miss_samples_len: {}", - app.kv_cache_miss_samples.len() + app.kv_cache.kv_cache_miss_samples.len() )); - push_cache_baseline(&mut lines, "baseline", app.kv_cache_baseline.as_ref()); - if let Some(request) = app.pending_kv_cache_request.as_ref() { + push_cache_baseline(&mut lines, "baseline", app.kv_cache.kv_cache_baseline.as_ref()); + if let Some(request) = app.kv_cache.pending_kv_cache_request.as_ref() { lines.push("- pending_request: present".to_string()); lines.push(format!( "- pending_request.turn_number: {}", @@ -1469,10 +1469,10 @@ fn format_cache_stats(app: &App) -> String { lines.push(String::new()); lines.push("Recent miss attributions".to_string()); - if app.kv_cache_miss_samples.is_empty() { + if app.kv_cache.kv_cache_miss_samples.is_empty() { lines.push("- none attributed".to_string()); } else { - for sample in app.kv_cache_miss_samples.iter().rev() { + for sample in app.kv_cache.kv_cache_miss_samples.iter().rev() { lines.push(format!( "- turn={} call={} missed_tokens={} reason={}", sample.turn_number, diff --git a/crates/jcode-tui/src/tui/app/tests.rs b/crates/jcode-tui/src/tui/app/tests.rs index 96a403f3e..898c72e5d 100644 --- a/crates/jcode-tui/src/tui/app/tests.rs +++ b/crates/jcode-tui/src/tui/app/tests.rs @@ -141,7 +141,7 @@ fn cold_cache_warning_is_persisted_when_starting_next_request() { crate::provider::anthropic::set_cache_ttl_1h(true); app.display_messages.push(DisplayMessage::user("first")); let session_id = app.kv_cache_session_id(); - app.kv_cache_baseline = Some(KvCacheBaseline { + app.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id, input_tokens: 911_873, completed_at: Instant::now() - Duration::from_secs(3723), @@ -190,7 +190,7 @@ fn kv_cache_baseline_from_other_session_is_ignored() { .map(|i| Message::user(format!("big session message {i}").as_str())) .collect(); let big_signature = App::kv_cache_request_signature(&big_history, &[], "system", ""); - app.kv_cache_baseline = Some(KvCacheBaseline { + app.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: Some("session_big".to_string()), input_tokens: 200_000, completed_at: Instant::now(), @@ -211,7 +211,7 @@ fn kv_cache_baseline_from_other_session_is_ignored() { app.begin_remote_kv_cache_request(small_signature); let request = app - .pending_kv_cache_request + .kv_cache.pending_kv_cache_request .as_ref() .expect("request should be pending"); assert!( @@ -236,7 +236,7 @@ fn kv_cache_baseline_same_session_still_compares() { Message::assistant_text("first answer"), ]; let baseline_signature = App::kv_cache_request_signature(&history, &[], "system", ""); - app.kv_cache_baseline = Some(KvCacheBaseline { + app.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: Some("session_same".to_string()), input_tokens: 1_000, completed_at: Instant::now(), @@ -253,7 +253,7 @@ fn kv_cache_baseline_same_session_still_compares() { app.begin_remote_kv_cache_request(grown_signature); let request = app - .pending_kv_cache_request + .kv_cache.pending_kv_cache_request .as_ref() .expect("request should be pending"); assert!( @@ -312,7 +312,7 @@ fn remote_token_usage_records_cache_stats_before_done_and_dedupes_snapshots() { assert_eq!(app.token_accounting.last_cache_reported_input_tokens, Some(63_762)); assert_eq!(app.token_accounting.total_input_tokens, 63_762); assert!(app.last_api_completed.is_some()); - assert!(app.pending_kv_cache_request.is_none()); + assert!(app.kv_cache.pending_kv_cache_request.is_none()); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 7f14e89c8..2e2855cf5 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -307,12 +307,7 @@ impl App { connection_type: None, status_detail: None, token_accounting: TokenAccounting::default(), - kv_cache_baseline: None, - pending_kv_cache_request: None, - current_api_usage_recorded: false, - kv_cache_turn_number: None, - kv_cache_turn_call_index: 0, - kv_cache_miss_samples: Vec::new(), + kv_cache: KvCacheState::default(), total_cost: 0.0, cached_prompt_price: None, cached_completion_price: None, @@ -698,12 +693,7 @@ impl App { connection_type: None, status_detail: None, token_accounting: TokenAccounting::default(), - kv_cache_baseline: None, - pending_kv_cache_request: None, - current_api_usage_recorded: false, - kv_cache_turn_number: None, - kv_cache_turn_call_index: 0, - kv_cache_miss_samples: Vec::new(), + kv_cache: KvCacheState::default(), total_cost: 0.0, cached_prompt_price: None, cached_completion_price: None, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index b828a137b..7f00d1534 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -1210,7 +1210,7 @@ impl crate::tui::TuiState for App { last_creation_tokens: self.token_accounting.last_cache_creation_tokens, last_optimal_input_tokens: self.token_accounting.last_cache_optimal_input_tokens, miss_attributions: self - .kv_cache_miss_samples + .kv_cache.kv_cache_miss_samples .iter() .rev() .map(|sample| crate::tui::info_widget::CacheMissAttribution { From 95048671405a4564434f9b2f25c2223de8473625 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:37:49 -0700 Subject: [PATCH 11/41] refactor(tui): group streaming/turn progress fields into StreamingProgress --- crates/jcode-tui/src/tui/app.rs | 107 +++++++------ crates/jcode-tui/src/tui/app/commands.rs | 20 +-- .../jcode-tui/src/tui/app/commands_improve.rs | 20 +-- .../src/tui/app/commands_overnight.rs | 20 +-- crates/jcode-tui/src/tui/app/debug.rs | 2 +- crates/jcode-tui/src/tui/app/debug_profile.rs | 2 +- crates/jcode-tui/src/tui/app/input.rs | 92 +++++------ crates/jcode-tui/src/tui/app/local.rs | 4 +- crates/jcode-tui/src/tui/app/misc_ui.rs | 44 +++--- crates/jcode-tui/src/tui/app/model_context.rs | 16 +- crates/jcode-tui/src/tui/app/navigation.rs | 8 +- crates/jcode-tui/src/tui/app/remote.rs | 4 +- .../src/tui/app/remote/server_events.rs | 58 +++---- crates/jcode-tui/src/tui/app/replay.rs | 12 +- crates/jcode-tui/src/tui/app/run_shell.rs | 2 +- crates/jcode-tui/src/tui/app/split_view.rs | 2 +- crates/jcode-tui/src/tui/app/state_ui.rs | 40 ++--- .../jcode-tui/src/tui/app/state_ui_runtime.rs | 38 ++--- crates/jcode-tui/src/tui/app/tests.rs | 6 +- .../app/tests/commands_accounts_01/part_01.rs | 2 +- .../tests/remote_events_reload_01/part_01.rs | 76 ++++----- .../tests/remote_events_reload_02/part_01.rs | 8 +- .../tui/app/tests/remote_events_reload_04.rs | 12 +- .../tests/remote_startup_input_02/part_01.rs | 2 +- .../tests/remote_startup_input_02/part_02.rs | 4 +- .../tests/remote_startup_input_03/part_01.rs | 12 +- .../tui/app/tests/scroll_copy_01/part_01.rs | 14 +- .../tui/app/tests/scroll_copy_02/part_01.rs | 4 +- .../src/tui/app/tests/scroll_copy_03.rs | 4 +- .../app/tests/state_model_poke_01/part_01.rs | 146 +++++++++--------- crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 24 +-- crates/jcode-tui/src/tui/app/tui_state.rs | 14 +- crates/jcode-tui/src/tui/app/turn.rs | 32 ++-- 33 files changed, 421 insertions(+), 430 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index f2aa540bf..8840adbba 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -581,6 +581,44 @@ struct KvCacheState { kv_cache_miss_samples: Vec, } +/// Live streaming/turn progress: streamed text, per-turn token counts, and the +/// tokens-per-second tracking state. +/// +/// Grouped out of [`App`]. These fields are reset/updated as a unit each turn, +/// so keeping them together clarifies the streaming lifecycle. +#[derive(Clone, Debug, Default)] +struct StreamingProgress { + streaming_text: String, + // Live token usage (per turn) + streaming_input_tokens: u64, + streaming_output_tokens: u64, + streaming_cache_read_tokens: Option, + streaming_cache_creation_tokens: Option, + // Accurate TPS tracking: counts model output generation time, not tool execution. + /// Set while the provider is generating output tokens (text, reasoning, or tool-call JSON). + streaming_tps_start: Option, + /// Accumulated model-output generation time across agentic loop iterations. + streaming_tps_elapsed: Duration, + /// Whether incoming provider output-token deltas should contribute to TPS. + /// + /// This is enabled while an API call has generated model output, and can stay enabled + /// briefly after generation ends so late final usage snapshots still count. + streaming_tps_collect_output: bool, + /// Accumulated output tokens across all API calls in a turn. + /// + /// Providers may emit repeated cumulative usage snapshots for a single API call, + /// so we accumulate per-call deltas to avoid double counting. + streaming_total_output_tokens: u64, + /// Latest provider output-token snapshot used for TPS display. + /// + /// We update this only when newly generated output tokens are observed. That keeps the + /// displayed TPS anchored to the latest real token sample instead of decaying on every + /// redraw while no new usage data has arrived. + streaming_tps_observed_output_tokens: u64, + /// Streaming-only elapsed time corresponding to streaming_tps_observed_output_tokens. + streaming_tps_observed_elapsed: Duration, +} + /// State for an in-progress OAuth/API-key login flow triggered by `/login`. /// TUI Application state pub struct App { @@ -603,17 +641,13 @@ pub struct App { auto_scroll_paused: bool, active_skill: Option, is_processing: bool, - streaming_text: String, + // Live streaming/turn progress (text, per-turn tokens, TPS tracking). + streaming: StreamingProgress, should_quit: bool, // Message queueing queued_messages: Vec, hidden_queued_system_messages: Vec, current_turn_system_reminder: Option, - // Live token usage (per turn) - streaming_input_tokens: u64, - streaming_output_tokens: u64, - streaming_cache_read_tokens: Option, - streaming_cache_creation_tokens: Option, // Upstream provider (e.g., which provider OpenRouter routed to) upstream_provider: Option, // Active stream connection type (websocket/https/etc.) @@ -649,29 +683,6 @@ pub struct App { remote_resume_activity: Option, // Reload reconnect is waiting for server history before deciding whether to continue. pending_reload_reconnect_status: Option, - // Accurate TPS tracking: counts model output generation time, not tool execution. - /// Set while the provider is generating output tokens (text, reasoning, or tool-call JSON). - streaming_tps_start: Option, - /// Accumulated model-output generation time across agentic loop iterations. - streaming_tps_elapsed: Duration, - /// Whether incoming provider output-token deltas should contribute to TPS. - /// - /// This is enabled while an API call has generated model output, and can stay enabled - /// briefly after generation ends so late final usage snapshots still count. - streaming_tps_collect_output: bool, - /// Accumulated output tokens across all API calls in a turn. - /// - /// Providers may emit repeated cumulative usage snapshots for a single API call, - /// so we accumulate per-call deltas to avoid double counting. - streaming_total_output_tokens: u64, - /// Latest provider output-token snapshot used for TPS display. - /// - /// We update this only when newly generated output tokens are observed. That keeps the - /// displayed TPS anchored to the latest real token sample instead of decaying on every - /// redraw while no new usage data has arrived. - streaming_tps_observed_output_tokens: u64, - /// Streaming-only elapsed time corresponding to streaming_tps_observed_output_tokens. - streaming_tps_observed_elapsed: Duration, // Current status status: ProcessingStatus, // Subagent status (shown during Task tool execution) @@ -1374,12 +1385,12 @@ impl App { } pub(super) fn record_completed_stream_cache_usage(&mut self) -> bool { - let has_cache_telemetry = self.streaming_cache_read_tokens.is_some() - || self.streaming_cache_creation_tokens.is_some(); + let has_cache_telemetry = self.streaming.streaming_cache_read_tokens.is_some() + || self.streaming.streaming_cache_creation_tokens.is_some(); if self.kv_cache.current_api_usage_recorded { return false; } - if self.streaming_input_tokens == 0 { + if self.streaming.streaming_input_tokens == 0 { return false; } @@ -1390,9 +1401,9 @@ impl App { // uncached remainder, so the reusable prefix is input + read + creation. self.token_accounting.cache_next_optimal_input_tokens = Some(crate::tui::info_widget::effective_prompt_tokens( - self.streaming_input_tokens, - self.streaming_cache_read_tokens.unwrap_or(0), - self.streaming_cache_creation_tokens.unwrap_or(0), + self.streaming.streaming_input_tokens, + self.streaming.streaming_cache_read_tokens.unwrap_or(0), + self.streaming.streaming_cache_creation_tokens.unwrap_or(0), )); let request = self @@ -1408,7 +1419,7 @@ impl App { if !has_cache_telemetry { self.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: baseline_session_id, - input_tokens: self.streaming_input_tokens, + input_tokens: self.streaming.streaming_input_tokens, completed_at: Instant::now(), provider: request.provider, model: request.model, @@ -1420,7 +1431,7 @@ impl App { self.token_accounting.total_cache_reported_input_tokens = self .token_accounting.total_cache_reported_input_tokens - .saturating_add(self.streaming_input_tokens); + .saturating_add(self.streaming.streaming_input_tokens); if let Some(optimal) = optimal_input_tokens { self.token_accounting.total_cache_optimal_input_tokens = self .token_accounting.total_cache_optimal_input_tokens @@ -1428,20 +1439,20 @@ impl App { } self.token_accounting.total_cache_read_tokens = self .token_accounting.total_cache_read_tokens - .saturating_add(self.streaming_cache_read_tokens.unwrap_or(0)); + .saturating_add(self.streaming.streaming_cache_read_tokens.unwrap_or(0)); self.token_accounting.total_cache_creation_tokens = self .token_accounting.total_cache_creation_tokens - .saturating_add(self.streaming_cache_creation_tokens.unwrap_or(0)); - self.token_accounting.last_cache_reported_input_tokens = Some(self.streaming_input_tokens); - self.token_accounting.last_cache_read_tokens = Some(self.streaming_cache_read_tokens.unwrap_or(0)); - self.token_accounting.last_cache_creation_tokens = Some(self.streaming_cache_creation_tokens.unwrap_or(0)); + .saturating_add(self.streaming.streaming_cache_creation_tokens.unwrap_or(0)); + self.token_accounting.last_cache_reported_input_tokens = Some(self.streaming.streaming_input_tokens); + self.token_accounting.last_cache_read_tokens = Some(self.streaming.streaming_cache_read_tokens.unwrap_or(0)); + self.token_accounting.last_cache_creation_tokens = Some(self.streaming.streaming_cache_creation_tokens.unwrap_or(0)); self.token_accounting.last_cache_optimal_input_tokens = optimal_input_tokens; self.log_kv_cache_usage_summary(&request, optimal_input_tokens); self.kv_cache.kv_cache_baseline = Some(KvCacheBaseline { session_id: baseline_session_id, - input_tokens: self.streaming_input_tokens, + input_tokens: self.streaming.streaming_input_tokens, completed_at: Instant::now(), provider: request.provider, model: request.model, @@ -1456,9 +1467,9 @@ impl App { request: &PendingKvCacheRequest, optimal_input_tokens: Option, ) { - let input_tokens = self.streaming_input_tokens; - let read_tokens = self.streaming_cache_read_tokens.unwrap_or(0); - let creation_tokens = self.streaming_cache_creation_tokens.unwrap_or(0); + let input_tokens = self.streaming.streaming_input_tokens; + let read_tokens = self.streaming.streaming_cache_read_tokens.unwrap_or(0); + let creation_tokens = self.streaming.streaming_cache_creation_tokens.unwrap_or(0); let read_pct = ratio_pct(read_tokens, input_tokens); let creation_pct = ratio_pct(creation_tokens, input_tokens); let optimal_read_pct = optimal_input_tokens.map(|optimal| ratio_pct(read_tokens, optimal)); @@ -1659,7 +1670,7 @@ impl App { return; } - let read_tokens = self.streaming_cache_read_tokens.unwrap_or(0); + let read_tokens = self.streaming.streaming_cache_read_tokens.unwrap_or(0); let missed_tokens = expected_tokens.saturating_sub(read_tokens); if missed_tokens < Self::KV_CACHE_MIN_MISSED_TOKENS { return; @@ -1737,7 +1748,7 @@ impl App { return KvCacheMissReason::HarnessPrefixChanged; } - if self.streaming_cache_read_tokens.is_none() { + if self.streaming.streaming_cache_read_tokens.is_none() { return KvCacheMissReason::Unknown; } if read_tokens == 0 { diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 210aa9fd5..c60813566 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -302,19 +302,19 @@ pub(super) fn activate_auto_poke_local(app: &mut App) { app.thinking_buffer.clear(); app.streaming_tool_calls.clear(); app.batch_progress = None; - app.streaming_input_tokens = 0; - app.streaming_output_tokens = 0; - app.streaming_cache_read_tokens = None; - app.streaming_cache_creation_tokens = None; + app.streaming.streaming_input_tokens = 0; + app.streaming.streaming_output_tokens = 0; + app.streaming.streaming_cache_read_tokens = None; + app.streaming.streaming_cache_creation_tokens = None; app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; - app.streaming_tps_start = None; - app.streaming_tps_elapsed = std::time::Duration::ZERO; - app.streaming_tps_collect_output = false; - app.streaming_total_output_tokens = 0; - app.streaming_tps_observed_output_tokens = 0; - app.streaming_tps_observed_elapsed = std::time::Duration::ZERO; + app.streaming.streaming_tps_start = None; + app.streaming.streaming_tps_elapsed = std::time::Duration::ZERO; + app.streaming.streaming_tps_collect_output = false; + app.streaming.streaming_total_output_tokens = 0; + app.streaming.streaming_tps_observed_output_tokens = 0; + app.streaming.streaming_tps_observed_elapsed = std::time::Duration::ZERO; app.processing_started = Some(Instant::now()); app.visible_turn_started = Some(Instant::now()); app.pending_turn = true; diff --git a/crates/jcode-tui/src/tui/app/commands_improve.rs b/crates/jcode-tui/src/tui/app/commands_improve.rs index cba26c9b2..914c365ad 100644 --- a/crates/jcode-tui/src/tui/app/commands_improve.rs +++ b/crates/jcode-tui/src/tui/app/commands_improve.rs @@ -419,19 +419,19 @@ pub(super) fn start_synthetic_user_turn(app: &mut App, content: String) { app.thinking_buffer.clear(); app.streaming_tool_calls.clear(); app.batch_progress = None; - app.streaming_input_tokens = 0; - app.streaming_output_tokens = 0; - app.streaming_cache_read_tokens = None; - app.streaming_cache_creation_tokens = None; + app.streaming.streaming_input_tokens = 0; + app.streaming.streaming_output_tokens = 0; + app.streaming.streaming_cache_read_tokens = None; + app.streaming.streaming_cache_creation_tokens = None; app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; - app.streaming_tps_start = None; - app.streaming_tps_elapsed = std::time::Duration::ZERO; - app.streaming_tps_collect_output = false; - app.streaming_total_output_tokens = 0; - app.streaming_tps_observed_output_tokens = 0; - app.streaming_tps_observed_elapsed = std::time::Duration::ZERO; + app.streaming.streaming_tps_start = None; + app.streaming.streaming_tps_elapsed = std::time::Duration::ZERO; + app.streaming.streaming_tps_collect_output = false; + app.streaming.streaming_total_output_tokens = 0; + app.streaming.streaming_tps_observed_output_tokens = 0; + app.streaming.streaming_tps_observed_elapsed = std::time::Duration::ZERO; app.processing_started = Some(Instant::now()); app.visible_turn_started = Some(Instant::now()); app.pending_turn = true; diff --git a/crates/jcode-tui/src/tui/app/commands_overnight.rs b/crates/jcode-tui/src/tui/app/commands_overnight.rs index 986fa19b8..6288095ba 100644 --- a/crates/jcode-tui/src/tui/app/commands_overnight.rs +++ b/crates/jcode-tui/src/tui/app/commands_overnight.rs @@ -97,19 +97,19 @@ fn start_visible_overnight_turn(app: &mut App, content: String) { app.thinking_buffer.clear(); app.streaming_tool_calls.clear(); app.batch_progress = None; - app.streaming_input_tokens = 0; - app.streaming_output_tokens = 0; - app.streaming_cache_read_tokens = None; - app.streaming_cache_creation_tokens = None; + app.streaming.streaming_input_tokens = 0; + app.streaming.streaming_output_tokens = 0; + app.streaming.streaming_cache_read_tokens = None; + app.streaming.streaming_cache_creation_tokens = None; app.kv_cache.current_api_usage_recorded = false; app.upstream_provider = None; app.status_detail = None; - app.streaming_tps_start = None; - app.streaming_tps_elapsed = Duration::ZERO; - app.streaming_tps_collect_output = false; - app.streaming_total_output_tokens = 0; - app.streaming_tps_observed_output_tokens = 0; - app.streaming_tps_observed_elapsed = Duration::ZERO; + app.streaming.streaming_tps_start = None; + app.streaming.streaming_tps_elapsed = Duration::ZERO; + app.streaming.streaming_tps_collect_output = false; + app.streaming.streaming_total_output_tokens = 0; + app.streaming.streaming_tps_observed_output_tokens = 0; + app.streaming.streaming_tps_observed_elapsed = Duration::ZERO; app.processing_started = Some(Instant::now()); app.visible_turn_started = Some(Instant::now()); app.pending_turn = true; diff --git a/crates/jcode-tui/src/tui/app/debug.rs b/crates/jcode-tui/src/tui/app/debug.rs index 7bcea4983..040139e5e 100644 --- a/crates/jcode-tui/src/tui/app/debug.rs +++ b/crates/jcode-tui/src/tui/app/debug.rs @@ -490,7 +490,7 @@ impl ScrollTestState { diff_pane_focus: app.diff_pane_focus, diff_pane_auto_scroll: app.diff_pane_auto_scroll, is_processing: app.is_processing, - streaming_text: app.streaming_text.clone(), + streaming_text: app.streaming.streaming_text.clone(), queued_messages: app.queued_messages.clone(), interleave_message: app.interleave_message.clone(), pending_soft_interrupts: app.pending_soft_interrupts.clone(), diff --git a/crates/jcode-tui/src/tui/app/debug_profile.rs b/crates/jcode-tui/src/tui/app/debug_profile.rs index 7d38ebff6..9cb67b449 100644 --- a/crates/jcode-tui/src/tui/app/debug_profile.rs +++ b/crates/jcode-tui/src/tui/app/debug_profile.rs @@ -120,7 +120,7 @@ impl App { "cursor_pos": self.cursor_pos, }, "streaming": { - "streaming_text_bytes": self.streaming_text.len(), + "streaming_text_bytes": self.streaming.streaming_text.len(), "thinking_buffer_bytes": self.thinking_buffer.len(), "stream_buffer": self.stream_buffer.debug_memory_profile(), "streaming_tool_calls_count": self.streaming_tool_calls.len(), diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 8d3749964..7af4089b8 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -2375,10 +2375,10 @@ impl App { prefix.push('\n'); } prefix.push('\n'); - if self.streaming_text.is_empty() { + if self.streaming.streaming_text.is_empty() { self.replace_streaming_text(prefix); } else { - self.replace_streaming_text(format!("{}{}", prefix, self.streaming_text)); + self.replace_streaming_text(format!("{}{}", prefix, self.streaming.streaming_text)); } } @@ -2389,10 +2389,10 @@ impl App { return; } // Separate the reasoning block from any prior content with a blank line. - if !self.streaming_text.is_empty() { - if self.streaming_text.ends_with("\n\n") { + if !self.streaming.streaming_text.is_empty() { + if self.streaming.streaming_text.ends_with("\n\n") { // already separated - } else if self.streaming_text.ends_with('\n') { + } else if self.streaming.streaming_text.ends_with('\n') { self.append_streaming_text("\n"); } else { self.append_streaming_text("\n\n"); @@ -2404,7 +2404,7 @@ impl App { // Remember where this reasoning block starts in the stream so `current` // mode can later slice it back out in place (without disturbing any // preceding answer text) once the model starts answering. - self.reasoning_block_start = Some(self.streaming_text.len()); + self.reasoning_block_start = Some(self.streaming.streaming_text.len()); } /// Remove the live partial-reasoning tail (the rendered, not-yet-committed @@ -2413,10 +2413,10 @@ impl App { fn strip_reasoning_partial_tail(&mut self) { if self.reasoning_partial_len > 0 { let new_len = self - .streaming_text + .streaming.streaming_text .len() .saturating_sub(self.reasoning_partial_len); - self.streaming_text.truncate(new_len); + self.streaming.streaming_text.truncate(new_len); self.reasoning_partial_len = 0; } } @@ -2446,12 +2446,12 @@ impl App { } } if !committed.is_empty() { - self.streaming_text.push_str(&committed); + self.streaming.streaming_text.push_str(&committed); } // Re-append the live tail for the in-progress (partial) line. let partial = jcode_tui_markdown::reasoning_partial_markup(&self.reasoning_pending_line); self.reasoning_partial_len = partial.len(); - self.streaming_text.push_str(&partial); + self.streaming.streaming_text.push_str(&partial); self.refresh_split_view_if_needed(); } @@ -2466,7 +2466,7 @@ impl App { self.strip_reasoning_partial_tail(); let pending = std::mem::take(&mut self.reasoning_pending_line); if !pending.is_empty() { - self.streaming_text + self.streaming.streaming_text .push_str(&jcode_tui_markdown::reasoning_line_markup(&pending)); } self.reasoning_streaming = false; @@ -2486,11 +2486,11 @@ impl App { // Terminate the reasoning block with a blank line so following output // renders as a normal paragraph. - if !self.streaming_text.ends_with("\n\n") { - if self.streaming_text.ends_with('\n') { - self.streaming_text.push('\n'); + if !self.streaming.streaming_text.ends_with("\n\n") { + if self.streaming.streaming_text.ends_with('\n') { + self.streaming.streaming_text.push('\n'); } else { - self.streaming_text.push_str("\n\n"); + self.streaming.streaming_text.push_str("\n\n"); } } self.refresh_split_view_if_needed(); @@ -2505,14 +2505,14 @@ impl App { .reasoning_block_start .take() .unwrap_or(0) - .min(self.streaming_text.len()); + .min(self.streaming.streaming_text.len()); // Everything from the block start onward is reasoning markup (plus the // separators inserted by open/close). Drop it from the live stream. - self.streaming_text.truncate(block_start); + self.streaming.streaming_text.truncate(block_start); // Drop the separator the open path added before the reasoning block so the // surrounding answer text rejoins cleanly. - while self.streaming_text.ends_with('\n') { - self.streaming_text.pop(); + while self.streaming.streaming_text.ends_with('\n') { + self.streaming.streaming_text.pop(); } self.refresh_split_view_if_needed(); } @@ -2521,7 +2521,7 @@ impl App { if text.is_empty() { return; } - self.streaming_text.push_str(text); + self.streaming.streaming_text.push_str(text); self.refresh_split_view_if_needed(); } @@ -2540,12 +2540,12 @@ impl App { } pub(super) fn replace_streaming_text(&mut self, text: String) { - self.streaming_text = text; + self.streaming.streaming_text = text; self.refresh_split_view_if_needed(); } pub(super) fn clear_streaming_render_state(&mut self) { - self.streaming_text.clear(); + self.streaming.streaming_text.clear(); self.stream_message_ended = false; self.reasoning_streaming = false; self.reasoning_pending_line.clear(); @@ -2558,7 +2558,7 @@ impl App { } pub(super) fn take_streaming_text(&mut self) -> String { - let content = std::mem::take(&mut self.streaming_text); + let content = std::mem::take(&mut self.streaming.streaming_text); self.stream_message_ended = false; self.reasoning_streaming = false; self.reasoning_pending_line.clear(); @@ -2575,7 +2575,7 @@ impl App { self.append_streaming_text(&chunk); } - if self.streaming_text.is_empty() { + if self.streaming.streaming_text.is_empty() { self.stream_buffer.clear(); return false; } @@ -2604,8 +2604,8 @@ impl App { // treat this as a reset and count the full value once. output_tokens }; - if self.streaming_tps_collect_output { - self.streaming_total_output_tokens += delta; + if self.streaming.streaming_tps_collect_output { + self.streaming.streaming_total_output_tokens += delta; if delta > 0 { self.snapshot_streaming_tps(); } @@ -2820,19 +2820,19 @@ impl App { self.thinking_prefix_emitted = false; self.thinking_buffer.clear(); self.streaming_tool_calls.clear(); - self.streaming_input_tokens = 0; - self.streaming_output_tokens = 0; - self.streaming_cache_read_tokens = None; - self.streaming_cache_creation_tokens = None; + self.streaming.streaming_input_tokens = 0; + self.streaming.streaming_output_tokens = 0; + self.streaming.streaming_cache_read_tokens = None; + self.streaming.streaming_cache_creation_tokens = None; self.kv_cache.current_api_usage_recorded = false; self.upstream_provider = None; self.status_detail = None; - self.streaming_tps_start = None; - self.streaming_tps_elapsed = Duration::ZERO; - self.streaming_tps_collect_output = false; - self.streaming_total_output_tokens = 0; - self.streaming_tps_observed_output_tokens = 0; - self.streaming_tps_observed_elapsed = Duration::ZERO; + self.streaming.streaming_tps_start = None; + self.streaming.streaming_tps_elapsed = Duration::ZERO; + self.streaming.streaming_tps_collect_output = false; + self.streaming.streaming_total_output_tokens = 0; + self.streaming.streaming_tps_observed_output_tokens = 0; + self.streaming.streaming_tps_observed_elapsed = Duration::ZERO; self.processing_started = Some(Instant::now()); self.visible_turn_started = Some(Instant::now()); self.pending_turn = true; @@ -2888,19 +2888,19 @@ impl App { self.thinking_prefix_emitted = false; self.thinking_buffer.clear(); self.streaming_tool_calls.clear(); - self.streaming_input_tokens = 0; - self.streaming_output_tokens = 0; - self.streaming_cache_read_tokens = None; - self.streaming_cache_creation_tokens = None; + self.streaming.streaming_input_tokens = 0; + self.streaming.streaming_output_tokens = 0; + self.streaming.streaming_cache_read_tokens = None; + self.streaming.streaming_cache_creation_tokens = None; self.kv_cache.current_api_usage_recorded = false; self.upstream_provider = None; self.status_detail = None; - self.streaming_tps_start = None; - self.streaming_tps_elapsed = Duration::ZERO; - self.streaming_tps_collect_output = false; - self.streaming_total_output_tokens = 0; - self.streaming_tps_observed_output_tokens = 0; - self.streaming_tps_observed_elapsed = Duration::ZERO; + self.streaming.streaming_tps_start = None; + self.streaming.streaming_tps_elapsed = Duration::ZERO; + self.streaming.streaming_tps_collect_output = false; + self.streaming.streaming_total_output_tokens = 0; + self.streaming.streaming_tps_observed_output_tokens = 0; + self.streaming.streaming_tps_observed_elapsed = Duration::ZERO; self.processing_started = Some(Instant::now()); if has_combined { if preserve_visible_turn { diff --git a/crates/jcode-tui/src/tui/app/local.rs b/crates/jcode-tui/src/tui/app/local.rs index 264c62ed5..ddab4bf2f 100644 --- a/crates/jcode-tui/src/tui/app/local.rs +++ b/crates/jcode-tui/src/tui/app/local.rs @@ -459,8 +459,8 @@ fn handle_input_shell_completed(app: &mut App, shell: InputShellCompleted) { } pub(super) fn finish_turn(app: &mut App) { - app.token_accounting.total_input_tokens += app.streaming_input_tokens; - app.token_accounting.total_output_tokens += app.streaming_output_tokens; + app.token_accounting.total_input_tokens += app.streaming.streaming_input_tokens; + app.token_accounting.total_output_tokens += app.streaming.streaming_output_tokens; app.update_cost_impl(); app.is_processing = false; app.status = ProcessingStatus::Idle; diff --git a/crates/jcode-tui/src/tui/app/misc_ui.rs b/crates/jcode-tui/src/tui/app/misc_ui.rs index b4ec9c688..34da93c0e 100644 --- a/crates/jcode-tui/src/tui/app/misc_ui.rs +++ b/crates/jcode-tui/src/tui/app/misc_ui.rs @@ -84,39 +84,39 @@ impl ResolvedTokenPricing { /// Update cost calculation based on token usage (for API-key providers) impl App { pub(super) fn current_streaming_tps_elapsed(&self) -> Duration { - let mut elapsed = self.streaming_tps_elapsed; - if let Some(start) = self.streaming_tps_start { + let mut elapsed = self.streaming.streaming_tps_elapsed; + if let Some(start) = self.streaming.streaming_tps_start { elapsed += start.elapsed(); } elapsed } pub(super) fn snapshot_streaming_tps(&mut self) { - self.streaming_tps_observed_output_tokens = self.streaming_total_output_tokens; - self.streaming_tps_observed_elapsed = self.current_streaming_tps_elapsed(); + self.streaming.streaming_tps_observed_output_tokens = self.streaming.streaming_total_output_tokens; + self.streaming.streaming_tps_observed_elapsed = self.current_streaming_tps_elapsed(); } pub(super) fn resume_streaming_tps(&mut self) { - self.streaming_tps_collect_output = true; - if self.streaming_tps_start.is_none() { - self.streaming_tps_start = Some(Instant::now()); + self.streaming.streaming_tps_collect_output = true; + if self.streaming.streaming_tps_start.is_none() { + self.streaming.streaming_tps_start = Some(Instant::now()); } } pub(super) fn pause_streaming_tps(&mut self, keep_collecting_output: bool) { - if let Some(start) = self.streaming_tps_start.take() { - self.streaming_tps_elapsed += start.elapsed(); + if let Some(start) = self.streaming.streaming_tps_start.take() { + self.streaming.streaming_tps_elapsed += start.elapsed(); } - self.streaming_tps_collect_output = keep_collecting_output; + self.streaming.streaming_tps_collect_output = keep_collecting_output; } pub(super) fn reset_streaming_tps(&mut self) { - self.streaming_tps_start = None; - self.streaming_tps_elapsed = Duration::ZERO; - self.streaming_tps_collect_output = false; - self.streaming_total_output_tokens = 0; - self.streaming_tps_observed_output_tokens = 0; - self.streaming_tps_observed_elapsed = Duration::ZERO; + self.streaming.streaming_tps_start = None; + self.streaming.streaming_tps_elapsed = Duration::ZERO; + self.streaming.streaming_tps_collect_output = false; + self.streaming.streaming_total_output_tokens = 0; + self.streaming.streaming_tps_observed_output_tokens = 0; + self.streaming.streaming_tps_observed_elapsed = Duration::ZERO; } pub(super) fn open_usage_inline_loading(&mut self) { @@ -231,10 +231,10 @@ impl App { }; self.total_cost += pricing.cost_for_usage( - self.streaming_input_tokens, - self.streaming_output_tokens, - self.streaming_cache_read_tokens.unwrap_or(0), - self.streaming_cache_creation_tokens.unwrap_or(0), + self.streaming.streaming_input_tokens, + self.streaming.streaming_output_tokens, + self.streaming.streaming_cache_read_tokens.unwrap_or(0), + self.streaming.streaming_cache_creation_tokens.unwrap_or(0), ); } @@ -355,8 +355,8 @@ impl App { } pub(super) fn compute_streaming_tps(&self) -> Option { - let elapsed_secs = self.streaming_tps_observed_elapsed.as_secs_f32(); - let total_tokens = self.streaming_tps_observed_output_tokens; + let elapsed_secs = self.streaming.streaming_tps_observed_elapsed.as_secs_f32(); + let total_tokens = self.streaming.streaming_tps_observed_output_tokens; if elapsed_secs > 0.1 && total_tokens > 0 { Some(total_tokens as f32 / elapsed_secs) } else { diff --git a/crates/jcode-tui/src/tui/app/model_context.rs b/crates/jcode-tui/src/tui/app/model_context.rs index 0e19a3e34..3433d1642 100644 --- a/crates/jcode-tui/src/tui/app/model_context.rs +++ b/crates/jcode-tui/src/tui/app/model_context.rs @@ -313,13 +313,13 @@ impl App { } pub(super) fn current_stream_context_tokens(&self) -> Option { - if self.streaming_input_tokens == 0 { + if self.streaming.streaming_input_tokens == 0 { return None; } Some(self.effective_context_tokens_from_usage( - self.streaming_input_tokens, - self.streaming_cache_read_tokens, - self.streaming_cache_creation_tokens, + self.streaming.streaming_input_tokens, + self.streaming.streaming_cache_read_tokens, + self.streaming.streaming_cache_creation_tokens, )) } @@ -465,10 +465,10 @@ impl App { self.clear_streaming_render_state(); self.stream_buffer.clear(); self.streaming_tool_calls.clear(); - self.streaming_input_tokens = 0; - self.streaming_output_tokens = 0; - self.streaming_cache_read_tokens = None; - self.streaming_cache_creation_tokens = None; + self.streaming.streaming_input_tokens = 0; + self.streaming.streaming_output_tokens = 0; + self.streaming.streaming_cache_read_tokens = None; + self.streaming.streaming_cache_creation_tokens = None; self.kv_cache.current_api_usage_recorded = false; self.thought_line_inserted = false; self.thinking_prefix_emitted = false; diff --git a/crates/jcode-tui/src/tui/app/navigation.rs b/crates/jcode-tui/src/tui/app/navigation.rs index 0fd1919bc..70283bb0f 100644 --- a/crates/jcode-tui/src/tui/app/navigation.rs +++ b/crates/jcode-tui/src/tui/app/navigation.rs @@ -204,7 +204,7 @@ impl App { self.display_messages .len() .saturating_mul(100) - .saturating_add(self.streaming_text.len()), + .saturating_add(self.streaming.streaming_text.len()), ); }; @@ -213,7 +213,7 @@ impl App { // measuring every message on each scroll input, which is noticeable in // very long sessions. The estimate below is only needed while streaming // can make LAST_MAX_SCROLL stale between frames. - if renderer_max > 0 && !self.is_processing && self.streaming_text.is_empty() { + if renderer_max > 0 && !self.is_processing && self.streaming.streaming_text.is_empty() { return renderer_max; } @@ -268,7 +268,7 @@ impl App { lines }); - message_lines.saturating_add(wrapped_text_lines(&self.streaming_text, width)) + message_lines.saturating_add(wrapped_text_lines(&self.streaming.streaming_text, width)) } pub(super) fn diagram_available(&self) -> bool { @@ -1440,7 +1440,7 @@ impl App { // `rendered_max` stale at 0 even though there is content to scroll. let bottom_threshold = if rendered_max > 0 { rendered_max.min(max) - } else if self.is_processing || !self.streaming_text.is_empty() { + } else if self.is_processing || !self.streaming.streaming_text.is_empty() { max } else { // Not streaming and nothing to scroll: we are already at the bottom. diff --git a/crates/jcode-tui/src/tui/app/remote.rs b/crates/jcode-tui/src/tui/app/remote.rs index db3d3f8ab..d9ac4f7f8 100644 --- a/crates/jcode-tui/src/tui/app/remote.rs +++ b/crates/jcode-tui/src/tui/app/remote.rs @@ -736,7 +736,7 @@ pub(super) fn handle_disconnect( if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); } - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let content = app.take_streaming_text(); let content = app.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { @@ -1249,7 +1249,7 @@ async fn detect_and_cancel_stall(app: &mut App, remote: &mut RemoteConnection) { app.current_message_id = None; app.processing_started = None; app.last_stream_activity = None; - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let content = app.take_streaming_text(); let content = app.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index 1f25571f3..d8a425583 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -412,19 +412,19 @@ pub(in crate::tui::app) fn handle_server_event( cache_read_input, cache_creation_input, } => { - let previous_input = app.streaming_input_tokens; - let previous_output = app.streaming_output_tokens; - let previous_cache_read = app.streaming_cache_read_tokens; - let previous_cache_creation = app.streaming_cache_creation_tokens; + let previous_input = app.streaming.streaming_input_tokens; + let previous_output = app.streaming.streaming_output_tokens; + let previous_cache_read = app.streaming.streaming_cache_read_tokens; + let previous_cache_creation = app.streaming.streaming_cache_creation_tokens; let was_recorded = app.kv_cache.current_api_usage_recorded; app.accumulate_streaming_output_tokens(output, call_output_tokens_seen); - app.streaming_input_tokens = input; - app.streaming_output_tokens = output; + app.streaming.streaming_input_tokens = input; + app.streaming.streaming_output_tokens = output; if cache_read_input.is_some() { - app.streaming_cache_read_tokens = cache_read_input; + app.streaming.streaming_cache_read_tokens = cache_read_input; } if cache_creation_input.is_some() { - app.streaming_cache_creation_tokens = cache_creation_input; + app.streaming.streaming_cache_creation_tokens = cache_creation_input; } if app.record_completed_stream_cache_usage() { app.token_accounting.total_input_tokens = @@ -437,8 +437,8 @@ pub(in crate::tui::app) fn handle_server_event( app.accrue_remote_call_cost( input, output, - app.streaming_cache_read_tokens.unwrap_or(0), - app.streaming_cache_creation_tokens.unwrap_or(0), + app.streaming.streaming_cache_read_tokens.unwrap_or(0), + app.streaming.streaming_cache_creation_tokens.unwrap_or(0), ); app.last_api_completed = Some(Instant::now()); app.last_api_completed_provider = Some(::provider_name(app)); @@ -457,18 +457,18 @@ pub(in crate::tui::app) fn handle_server_event( app.accrue_remote_call_cost( input.saturating_sub(previous_input), output.saturating_sub(previous_output), - app.streaming_cache_read_tokens + app.streaming.streaming_cache_read_tokens .unwrap_or(0) .saturating_sub(previous_cache_read.unwrap_or(0)), - app.streaming_cache_creation_tokens + app.streaming.streaming_cache_creation_tokens .unwrap_or(0) .saturating_sub(previous_cache_creation.unwrap_or(0)), ); let had_cache_telemetry = previous_cache_read.is_some() || previous_cache_creation.is_some(); - let has_cache_telemetry = app.streaming_cache_read_tokens.is_some() - || app.streaming_cache_creation_tokens.is_some(); + let has_cache_telemetry = app.streaming.streaming_cache_read_tokens.is_some() + || app.streaming.streaming_cache_creation_tokens.is_some(); if has_cache_telemetry { let reported_delta = if had_cache_telemetry { input.saturating_sub(previous_input) @@ -479,20 +479,20 @@ pub(in crate::tui::app) fn handle_server_event( .token_accounting.total_cache_reported_input_tokens .saturating_add(reported_delta); app.token_accounting.total_cache_read_tokens = app.token_accounting.total_cache_read_tokens.saturating_add( - app.streaming_cache_read_tokens + app.streaming.streaming_cache_read_tokens .unwrap_or(0) .saturating_sub(previous_cache_read.unwrap_or(0)), ); app.token_accounting.total_cache_creation_tokens = app.token_accounting.total_cache_creation_tokens.saturating_add( - app.streaming_cache_creation_tokens + app.streaming.streaming_cache_creation_tokens .unwrap_or(0) .saturating_sub(previous_cache_creation.unwrap_or(0)), ); app.token_accounting.last_cache_reported_input_tokens = Some(input); - app.token_accounting.last_cache_read_tokens = Some(app.streaming_cache_read_tokens.unwrap_or(0)); + app.token_accounting.last_cache_read_tokens = Some(app.streaming.streaming_cache_read_tokens.unwrap_or(0)); app.token_accounting.last_cache_creation_tokens = - Some(app.streaming_cache_creation_tokens.unwrap_or(0)); + Some(app.streaming.streaming_cache_creation_tokens.unwrap_or(0)); } if let Some(baseline) = app.kv_cache.kv_cache_baseline.as_mut() { @@ -502,8 +502,8 @@ pub(in crate::tui::app) fn handle_server_event( app.token_accounting.cache_next_optimal_input_tokens = Some(crate::tui::info_widget::effective_prompt_tokens( input, - app.streaming_cache_read_tokens.unwrap_or(0), - app.streaming_cache_creation_tokens.unwrap_or(0), + app.streaming.streaming_cache_read_tokens.unwrap_or(0), + app.streaming.streaming_cache_creation_tokens.unwrap_or(0), )); app.last_api_completed = Some(Instant::now()); app.last_api_completed_provider = Some(::provider_name(app)); @@ -597,7 +597,7 @@ pub(in crate::tui::app) fn handle_server_event( app.current_message_id, app.is_processing, app.status, - app.streaming_text.len(), + app.streaming.streaming_text.len(), app.pending_soft_interrupts.len(), app.queued_messages.len() )); @@ -612,7 +612,7 @@ pub(in crate::tui::app) fn handle_server_event( if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); } - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let content = app.take_streaming_text(); let content = app.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { @@ -665,7 +665,7 @@ pub(in crate::tui::app) fn handle_server_event( let has_resumed_turn_evidence = had_remote_resume_activity || app.stream_message_ended || app.has_streaming_footer_stats() - || !app.streaming_text.is_empty() + || !app.streaming.streaming_text.is_empty() || !app.streaming_tool_calls.is_empty() || matches!( app.status, @@ -686,7 +686,7 @@ pub(in crate::tui::app) fn handle_server_event( app.append_streaming_text(&chunk); } app.pause_streaming_tps(false); - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let duration = app.display_turn_duration_secs(); let content = app.take_streaming_text(); let content = app.collapse_reasoning_for_commit(content); @@ -1051,10 +1051,10 @@ pub(in crate::tui::app) fn handle_server_event( app.thought_line_inserted = false; app.thinking_prefix_emitted = false; app.thinking_buffer.clear(); - app.streaming_input_tokens = 0; - app.streaming_output_tokens = 0; - app.streaming_cache_read_tokens = None; - app.streaming_cache_creation_tokens = None; + app.streaming.streaming_input_tokens = 0; + app.streaming.streaming_output_tokens = 0; + app.streaming.streaming_cache_read_tokens = None; + app.streaming.streaming_cache_creation_tokens = None; app.kv_cache.current_api_usage_recorded = false; app.token_accounting.total_cache_reported_input_tokens = 0; app.token_accounting.total_cache_read_tokens = 0; @@ -1676,7 +1676,7 @@ pub(in crate::tui::app) fn handle_server_event( if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); } - if !app.streaming_text.is_empty() { + if !app.streaming.streaming_text.is_empty() { let duration = app.display_turn_duration_secs(); let flushed = app.take_streaming_text(); let flushed = app.collapse_reasoning_for_commit(flushed); diff --git a/crates/jcode-tui/src/tui/app/replay.rs b/crates/jcode-tui/src/tui/app/replay.rs index 4e619f921..900f397a2 100644 --- a/crates/jcode-tui/src/tui/app/replay.rs +++ b/crates/jcode-tui/src/tui/app/replay.rs @@ -410,12 +410,12 @@ pub(super) fn apply_replay_event( app.is_processing = true; app.processing_started = Some(Instant::now()); app.status = ProcessingStatus::Thinking(Instant::now()); - app.streaming_tps_start = None; - app.streaming_tps_elapsed = Duration::ZERO; - app.streaming_tps_collect_output = false; - app.streaming_total_output_tokens = 0; - app.streaming_tps_observed_output_tokens = 0; - app.streaming_tps_observed_elapsed = Duration::ZERO; + app.streaming.streaming_tps_start = None; + app.streaming.streaming_tps_elapsed = Duration::ZERO; + app.streaming.streaming_tps_collect_output = false; + app.streaming.streaming_total_output_tokens = 0; + app.streaming.streaming_tps_observed_output_tokens = 0; + app.streaming.streaming_tps_observed_elapsed = Duration::ZERO; app.replay_processing_started_ms = replay_processing_started_ms; } ReplayEvent::MemoryInjection { diff --git a/crates/jcode-tui/src/tui/app/run_shell.rs b/crates/jcode-tui/src/tui/app/run_shell.rs index 5bb954e75..4eedd0e71 100644 --- a/crates/jcode-tui/src/tui/app/run_shell.rs +++ b/crates/jcode-tui/src/tui/app/run_shell.rs @@ -64,7 +64,7 @@ pub(super) fn status_spinner_only_symbol(app: &App) -> Option<&'static str> { // When decorative animations are off it advances at the smooth liveness // rate; otherwise it uses the full-rate spinner clock. if !app.is_processing - || !app.streaming_text.is_empty() + || !app.streaming.streaming_text.is_empty() || app.centered_mode() || app.has_pending_mouse_scroll_animation() || app.remote_startup_phase_active() diff --git a/crates/jcode-tui/src/tui/app/split_view.rs b/crates/jcode-tui/src/tui/app/split_view.rs index cf127f127..36e64ea1d 100644 --- a/crates/jcode-tui/src/tui/app/split_view.rs +++ b/crates/jcode-tui/src/tui/app/split_view.rs @@ -99,7 +99,7 @@ impl App { } fn refresh_split_view_cache(&mut self, force: bool) -> bool { - let streaming_hash = hash_str(&self.streaming_text); + let streaming_hash = hash_str(&self.streaming.streaming_text); if !force && self.split_view_rendered_display_version == self.display_messages_version && self.split_view_rendered_streaming_hash == streaming_hash diff --git a/crates/jcode-tui/src/tui/app/state_ui.rs b/crates/jcode-tui/src/tui/app/state_ui.rs index 27967d5cd..839702560 100644 --- a/crates/jcode-tui/src/tui/app/state_ui.rs +++ b/crates/jcode-tui/src/tui/app/state_ui.rs @@ -677,7 +677,7 @@ impl App { tool_data: m.tool_data.clone(), }) .collect(), - streaming_text: self.streaming_text.clone(), + streaming_text: self.streaming.streaming_text.clone(), streaming_tool_calls: self.streaming_tool_calls.clone(), input: self.input.clone(), cursor_pos: self.cursor_pos, @@ -698,10 +698,10 @@ impl App { .map(|s| s.name.clone()) .collect(), session_id: self.provider_session_id.clone(), - input_tokens: self.streaming_input_tokens, - output_tokens: self.streaming_output_tokens, - cache_read_input_tokens: self.streaming_cache_read_tokens, - cache_creation_input_tokens: self.streaming_cache_creation_tokens, + input_tokens: self.streaming.streaming_input_tokens, + output_tokens: self.streaming.streaming_output_tokens, + cache_read_input_tokens: self.streaming.streaming_cache_read_tokens, + cache_creation_input_tokens: self.streaming.streaming_cache_creation_tokens, queued_messages: self.queued_messages.clone(), } } @@ -1006,23 +1006,23 @@ fn format_cache_stats(app: &App) -> String { (false, true) => "client_observed_api_calls", (false, false) => "none_yet", }; - let live_cache_telemetry = app.streaming_input_tokens > 0 + let live_cache_telemetry = app.streaming.streaming_input_tokens > 0 && !app.kv_cache.current_api_usage_recorded - && (app.streaming_cache_read_tokens.is_some() - || app.streaming_cache_creation_tokens.is_some()); + && (app.streaming.streaming_cache_read_tokens.is_some() + || app.streaming.streaming_cache_creation_tokens.is_some()); let live_reported = if live_cache_telemetry { - app.streaming_input_tokens + app.streaming.streaming_input_tokens } else { 0 }; let reported_including_live = reported.saturating_add(live_reported); let read_including_live = read.saturating_add(if live_cache_telemetry { - app.streaming_cache_read_tokens.unwrap_or(0) + app.streaming.streaming_cache_read_tokens.unwrap_or(0) } else { 0 }); let write_including_live = write.saturating_add(if live_cache_telemetry { - app.streaming_cache_creation_tokens.unwrap_or(0) + app.streaming.streaming_cache_creation_tokens.unwrap_or(0) } else { 0 }); @@ -1153,14 +1153,14 @@ fn format_cache_stats(app: &App) -> String { ) }; let live_unrecorded_input_tokens = - if app.streaming_input_tokens > 0 && !app.kv_cache.current_api_usage_recorded { - app.streaming_input_tokens + if app.streaming.streaming_input_tokens > 0 && !app.kv_cache.current_api_usage_recorded { + app.streaming.streaming_input_tokens } else { 0 }; let live_unrecorded_output_tokens = - if app.streaming_output_tokens > 0 && !app.kv_cache.current_api_usage_recorded { - app.streaming_output_tokens + if app.streaming.streaming_output_tokens > 0 && !app.kv_cache.current_api_usage_recorded { + app.streaming.streaming_output_tokens } else { 0 }; @@ -1332,23 +1332,23 @@ fn format_cache_stats(app: &App) -> String { lines.push("Current / live stream counters".to_string()); lines.push(format!( "- streaming_input_tokens: {}", - bold_count(app.streaming_input_tokens) + bold_count(app.streaming.streaming_input_tokens) )); lines.push(format!( "- streaming_output_tokens: {}", - bold_count(app.streaming_output_tokens) + bold_count(app.streaming.streaming_output_tokens) )); lines.push(format!( "- streaming_total_output_tokens: {}", - bold_count(app.streaming_total_output_tokens) + bold_count(app.streaming.streaming_total_output_tokens) )); lines.push(format!( "- streaming_cache_read_tokens: {}", - opt_u64(app.streaming_cache_read_tokens) + opt_u64(app.streaming.streaming_cache_read_tokens) )); lines.push(format!( "- streaming_cache_creation_tokens: {}", - opt_u64(app.streaming_cache_creation_tokens) + opt_u64(app.streaming.streaming_cache_creation_tokens) )); lines.push(format!( "- current_api_usage_recorded: {}", diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index 9ae17a20a..ea7d8ef46 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -23,7 +23,7 @@ impl App { } pub fn streaming_text(&self) -> &str { - &self.streaming_text + &self.streaming.streaming_text } pub fn active_skill(&self) -> Option<&str> { @@ -44,7 +44,7 @@ impl App { } pub fn streaming_tokens(&self) -> (u64, u64) { - (self.streaming_input_tokens, self.streaming_output_tokens) + (self.streaming.streaming_input_tokens, self.streaming.streaming_output_tokens) } pub(super) fn build_turn_footer(&self, duration: Option) -> Option { @@ -56,16 +56,16 @@ impl App { if let Some(tps) = self.compute_streaming_tps() { parts.push(format!("{:.1} tps", tps)); } - if self.streaming_input_tokens > 0 || self.streaming_output_tokens > 0 { + if self.streaming.streaming_input_tokens > 0 || self.streaming.streaming_output_tokens > 0 { parts.push(format!( "↑{} ↓{}", - format_tokens(self.streaming_input_tokens), - format_tokens(self.streaming_output_tokens) + format_tokens(self.streaming.streaming_input_tokens), + format_tokens(self.streaming.streaming_output_tokens) )); } if let Some(cache) = format_cache_footer( - self.streaming_cache_read_tokens, - self.streaming_cache_creation_tokens, + self.streaming.streaming_cache_read_tokens, + self.streaming.streaming_cache_creation_tokens, ) { parts.push(cache); } @@ -78,10 +78,10 @@ impl App { } pub(super) fn has_streaming_footer_stats(&self) -> bool { - self.streaming_input_tokens > 0 - || self.streaming_output_tokens > 0 - || self.streaming_cache_read_tokens.is_some() - || self.streaming_cache_creation_tokens.is_some() + self.streaming.streaming_input_tokens > 0 + || self.streaming.streaming_output_tokens > 0 + || self.streaming.streaming_cache_read_tokens.is_some() + || self.streaming.streaming_cache_creation_tokens.is_some() || self.compute_streaming_tps().is_some() } @@ -93,7 +93,7 @@ impl App { self.last_api_completed_provider = Some(::provider_name(self)); self.last_api_completed_model = Some(::provider_model(self)); self.last_turn_input_tokens = { - let input = self.streaming_input_tokens; + let input = self.streaming.streaming_input_tokens; if input > 0 { Some(input) } else { None } }; @@ -124,9 +124,9 @@ impl App { &provider, upstream_provider, user_turn_count, - self.streaming_input_tokens, - self.streaming_cache_read_tokens, - self.streaming_cache_creation_tokens, + self.streaming.streaming_input_tokens, + self.streaming.streaming_cache_read_tokens, + self.streaming.streaming_cache_creation_tokens, cache_ttl.as_ref(), ); @@ -134,12 +134,12 @@ impl App { // Collect context for debugging let session_id = self.session_id().to_string(); let model = ::provider_model(self); - let input_tokens = self.streaming_input_tokens; - let output_tokens = self.streaming_output_tokens; + let input_tokens = self.streaming.streaming_input_tokens; + let output_tokens = self.streaming.streaming_output_tokens; // Format as Option to distinguish None vs Some(0) - let cache_creation_dbg = format!("{:?}", self.streaming_cache_creation_tokens); - let cache_read_dbg = format!("{:?}", self.streaming_cache_read_tokens); + let cache_creation_dbg = format!("{:?}", self.streaming.streaming_cache_creation_tokens); + let cache_read_dbg = format!("{:?}", self.streaming.streaming_cache_read_tokens); // Count message types in conversation let mut user_msgs = 0; diff --git a/crates/jcode-tui/src/tui/app/tests.rs b/crates/jcode-tui/src/tui/app/tests.rs index 898c72e5d..aebc2ceca 100644 --- a/crates/jcode-tui/src/tui/app/tests.rs +++ b/crates/jcode-tui/src/tui/app/tests.rs @@ -943,9 +943,9 @@ fn remote_done_finalizes_resumed_activity_without_current_message_id() { observed_at: Instant::now(), current_tool_name: Some("bg".to_string()), }); - app.streaming_input_tokens = 63_762; - app.streaming_output_tokens = 153; - app.streaming_cache_read_tokens = Some(0); + app.streaming.streaming_input_tokens = 63_762; + app.streaming.streaming_output_tokens = 153; + app.streaming.streaming_cache_read_tokens = Some(0); app.stream_message_ended = true; app.handle_server_event(crate::protocol::ServerEvent::Done { id: 99 }, &mut remote); diff --git a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs index b79b6aec5..95433291a 100644 --- a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs @@ -974,7 +974,7 @@ fn test_splitview_mirrors_chat_and_streaming_text() { DisplayMessage::assistant("We decided to ship it.".to_string()), ]; app.bump_display_messages_version(); - app.streaming_text = "Working on the follow-up now...".to_string(); + app.streaming.streaming_text = "Working on the follow-up now...".to_string(); app.set_split_view_enabled(true, true); let page = app diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs index 419b638ce..411796e57 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs @@ -540,7 +540,7 @@ fn test_handle_server_event_token_usage_uses_per_call_deltas() { let _guard = rt.enter(); let mut remote = crate::tui::backend::RemoteConnection::dummy(); - app.streaming_tps_collect_output = true; + app.streaming.streaming_tps_collect_output = true; app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -570,8 +570,8 @@ fn test_handle_server_event_token_usage_uses_per_call_deltas() { &mut remote, ); - assert_eq!(app.streaming_output_tokens, 30); - assert_eq!(app.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_output_tokens, 30); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); assert_eq!(app.token_accounting.total_input_tokens, 100); assert_eq!(app.token_accounting.total_output_tokens, 30); } @@ -583,7 +583,7 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() let _guard = rt.enter(); let mut remote = crate::tui::backend::RemoteConnection::dummy(); - app.streaming_tps_elapsed = Duration::from_secs(2); + app.streaming.streaming_tps_elapsed = Duration::from_secs(2); app.handle_server_event( crate::protocol::ServerEvent::ToolStart { @@ -593,10 +593,10 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() &mut remote, ); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_some()); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_some()); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); app.handle_server_event( crate::protocol::ServerEvent::ToolExec { @@ -606,9 +606,9 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() &mut remote, ); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(5)); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(5)); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -620,8 +620,8 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() &mut remote, ); - assert_eq!(app.streaming_total_output_tokens, 25); - assert_eq!(app.streaming_tps_observed_output_tokens, 25); + assert_eq!(app.streaming.streaming_total_output_tokens, 25); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 25); app.handle_server_event( crate::protocol::ServerEvent::TextDelta { @@ -630,8 +630,8 @@ fn test_handle_server_event_tool_exec_pauses_tps_but_collects_final_tool_usage() &mut remote, ); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_some()); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_some()); } #[test] @@ -641,7 +641,7 @@ fn test_handle_server_event_kv_cache_request_resets_tps_output_watermark_for_nex let _guard = rt.enter(); let mut remote = crate::tui::backend::RemoteConnection::dummy(); - app.streaming_tps_collect_output = true; + app.streaming.streaming_tps_collect_output = true; app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -671,7 +671,7 @@ fn test_handle_server_event_kv_cache_request_resets_tps_output_watermark_for_nex &mut remote, ); - assert!(!app.streaming_tps_collect_output); + assert!(!app.streaming.streaming_tps_collect_output); app.handle_server_event( crate::protocol::ServerEvent::ConnectionPhase { @@ -680,7 +680,7 @@ fn test_handle_server_event_kv_cache_request_resets_tps_output_watermark_for_nex &mut remote, ); - assert!(app.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_collect_output); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -692,8 +692,8 @@ fn test_handle_server_event_kv_cache_request_resets_tps_output_watermark_for_nex &mut remote, ); - assert_eq!(app.streaming_total_output_tokens, 55); - assert_eq!(app.streaming_tps_observed_output_tokens, 55); + assert_eq!(app.streaming.streaming_total_output_tokens, 55); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 55); } #[test] @@ -705,7 +705,7 @@ fn test_handle_server_event_message_end_marks_stream_as_finalizing_without_stall app.is_processing = true; app.status = ProcessingStatus::Streaming; - app.streaming_tps_collect_output = true; + app.streaming.streaming_tps_collect_output = true; let needs_redraw = app.handle_server_event(crate::protocol::ServerEvent::MessageEnd, &mut remote); @@ -713,7 +713,7 @@ fn test_handle_server_event_message_end_marks_stream_as_finalizing_without_stall assert!(needs_redraw); assert!(app.stream_message_ended); assert!(matches!(app.status, ProcessingStatus::Streaming)); - assert!(app.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_collect_output); } #[test] @@ -730,8 +730,8 @@ fn test_handle_server_event_tps_connection_phase_streaming_starts_collection_onl &mut remote, ); - assert!(!app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); + assert!(!app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); app.handle_server_event( crate::protocol::ServerEvent::ConnectionPhase { @@ -740,8 +740,8 @@ fn test_handle_server_event_tps_connection_phase_streaming_starts_collection_onl &mut remote, ); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_some()); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_some()); assert!(matches!(app.status, ProcessingStatus::Streaming)); } @@ -758,13 +758,13 @@ fn test_handle_server_event_tps_message_end_counts_late_usage_without_timer_runn }, &mut remote, ); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(4)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(4)); app.handle_server_event(crate::protocol::ServerEvent::MessageEnd, &mut remote); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(4)); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(4)); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -776,10 +776,10 @@ fn test_handle_server_event_tps_message_end_counts_late_usage_without_timer_runn &mut remote, ); - assert_eq!(app.streaming_total_output_tokens, 20); - assert_eq!(app.streaming_tps_observed_output_tokens, 20); - assert!(app.streaming_tps_observed_elapsed >= Duration::from_secs(4)); - assert!(app.streaming_tps_start.is_none()); + assert_eq!(app.streaming.streaming_total_output_tokens, 20); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 20); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(4)); + assert!(app.streaming.streaming_tps_start.is_none()); } #[test] @@ -795,7 +795,7 @@ fn test_handle_server_event_tps_redundant_late_usage_after_message_end_does_not_ }, &mut remote, ); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(5)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(5)); app.handle_server_event( crate::protocol::ServerEvent::TokenUsage { @@ -826,8 +826,8 @@ fn test_handle_server_event_tps_redundant_late_usage_after_message_end_does_not_ &mut remote, ); - assert_eq!(app.streaming_total_output_tokens, 30); - assert_eq!(app.streaming_tps_observed_output_tokens, 30); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 30); assert_eq!(*remote.call_output_tokens_seen(), 30); } @@ -842,7 +842,7 @@ fn test_handle_server_event_interrupted_clears_stream_state_and_sets_idle() { app.status = ProcessingStatus::Streaming; app.processing_started = Some(Instant::now()); app.current_message_id = Some(42); - app.streaming_text = "partial".to_string(); + app.streaming.streaming_text = "partial".to_string(); app.streaming_tool_calls.push(crate::message::ToolCall { id: "tool_1".to_string(), name: "bash".to_string(), @@ -864,7 +864,7 @@ fn test_handle_server_event_interrupted_clears_stream_state_and_sets_idle() { assert!(matches!(app.status, ProcessingStatus::Idle)); assert!(app.processing_started.is_none()); assert!(app.current_message_id.is_none()); - assert!(app.streaming_text.is_empty()); + assert!(app.streaming.streaming_text.is_empty()); assert!(app.streaming_tool_calls.is_empty()); assert!(app.interleave_message.is_none()); assert_eq!(app.queued_messages(), &["queued interrupt"]); diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs index b0741706f..3a2d5626d 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_02/part_01.rs @@ -190,7 +190,7 @@ fn test_handle_server_event_tool_start_flushes_streaming_text_before_tool_messag app.is_processing = true; app.status = ProcessingStatus::Streaming; - app.streaming_text = "Let me inspect those files first.".to_string(); + app.streaming.streaming_text = "Let me inspect those files first.".to_string(); app.handle_server_event( crate::protocol::ServerEvent::ToolStart { @@ -200,7 +200,7 @@ fn test_handle_server_event_tool_start_flushes_streaming_text_before_tool_messag &mut remote, ); - assert!(app.streaming_text.is_empty()); + assert!(app.streaming.streaming_text.is_empty()); assert_eq!(app.display_messages().len(), 1); assert_eq!(app.display_messages()[0].role, "assistant"); assert_eq!( @@ -811,7 +811,7 @@ fn test_handle_remote_disconnect_flushes_streaming_text_and_sets_reconnect_state retry_attempts: 0, retry_at: None, }); - app.streaming_text = "partial response being streamed".to_string(); + app.streaming.streaming_text = "partial response being streamed".to_string(); let mut state = remote::RemoteRunState::default(); remote::handle_disconnect(&mut app, &mut state, None); @@ -820,7 +820,7 @@ fn test_handle_remote_disconnect_flushes_streaming_text_and_sets_reconnect_state assert!(matches!(app.status, ProcessingStatus::Idle)); assert!(app.current_message_id.is_none()); assert!(app.rate_limit_pending_message.is_none()); - assert!(app.streaming_text.is_empty()); + assert!(app.streaming.streaming_text.is_empty()); assert_eq!(state.disconnect_msg_idx, Some(1)); assert_eq!(state.reconnect_attempts, 1); assert!(state.disconnect_start.is_some()); diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs index 812ce749b..95f13793e 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs @@ -773,8 +773,8 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { crate::auth::AuthStatus::invalidate_cache(); let mut app = create_named_provider_test_app(provider_name, model); - app.streaming_input_tokens = 1_000; - app.streaming_output_tokens = 1_000; + app.streaming.streaming_input_tokens = 1_000; + app.streaming.streaming_output_tokens = 1_000; app.token_accounting.total_input_tokens = 12_000; app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); @@ -802,8 +802,8 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { crate::env::set_var("JCODE_RUNTIME_PROVIDER", "jcode"); crate::env::remove_var("JCODE_OPENROUTER_ALLOW_NO_AUTH"); let mut app = create_named_provider_test_app("openrouter", "subscription-model"); - app.streaming_input_tokens = 1_000; - app.streaming_output_tokens = 1_000; + app.streaming.streaming_input_tokens = 1_000; + app.streaming.streaming_output_tokens = 1_000; app.token_accounting.total_input_tokens = 12_000; app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); @@ -819,8 +819,8 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { crate::env::set_var("JCODE_RUNTIME_PROVIDER", "openai-compatible"); crate::env::set_var("JCODE_OPENROUTER_ALLOW_NO_AUTH", "1"); let mut app = create_named_provider_test_app("openrouter", "local-model"); - app.streaming_input_tokens = 1_000; - app.streaming_output_tokens = 1_000; + app.streaming.streaming_input_tokens = 1_000; + app.streaming.streaming_output_tokens = 1_000; app.token_accounting.total_input_tokens = 12_000; app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index d47bd6b78..c780dbc78 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -590,7 +590,7 @@ fn test_submit_input_commits_pending_streaming_assistant_text_before_user_messag intent: None, thought_signature: None, }, )); app.bump_display_messages_version(); - app.streaming_text = "Here is the final paragraph".to_string(); + app.streaming.streaming_text = "Here is the final paragraph".to_string(); // Mirror the real streaming caller: append any paced chunk the buffer reveals. // The paced StreamBuffer may reveal part of the text immediately, so commit // (below) must still flush the remainder. diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs index fc7be4c16..5f5d0ffe2 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_02.rs @@ -239,8 +239,8 @@ fn test_streaming_tokens() { assert_eq!(app.streaming_tokens(), (0, 0)); - app.streaming_input_tokens = 100; - app.streaming_output_tokens = 50; + app.streaming.streaming_input_tokens = 100; + app.streaming.streaming_output_tokens = 50; assert_eq!(app.streaming_tokens(), (100, 50)); } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs index 3ac987020..a2426ae47 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_03/part_01.rs @@ -1,12 +1,12 @@ #[test] fn test_build_turn_footer_combines_compact_duration_with_streaming_stats() { let mut app = create_test_app(); - app.streaming_input_tokens = 210_000; - app.streaming_output_tokens = 440; - app.streaming_tps_collect_output = true; - app.streaming_total_output_tokens = 440; - app.streaming_tps_observed_output_tokens = 440; - app.streaming_tps_observed_elapsed = Duration::from_secs(220); + app.streaming.streaming_input_tokens = 210_000; + app.streaming.streaming_output_tokens = 440; + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_total_output_tokens = 440; + app.streaming.streaming_tps_observed_output_tokens = 440; + app.streaming.streaming_tps_observed_elapsed = Duration::from_secs(220); let footer = app .build_turn_footer(Some(316.1)) diff --git a/crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs index 56052ce04..b6f1a5eca 100644 --- a/crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/scroll_copy_01/part_01.rs @@ -56,7 +56,7 @@ fn create_scroll_test_app( app.scroll_offset = 0; app.auto_scroll_paused = false; app.is_processing = false; - app.streaming_text.clear(); + app.streaming.streaming_text.clear(); app.status = ProcessingStatus::Idle; // Set deterministic session name for snapshot stability app.session.short_name = Some("test".to_string()); @@ -90,7 +90,7 @@ fn create_copy_test_app() -> (App, ratatui::Terminal (App, ratatui::Terminal (App, ratatui::Terminal= Duration::from_secs(9)); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 30); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(9)); assert_eq!(seen, 30); } @@ -196,25 +196,25 @@ fn test_accumulate_streaming_output_tokens_ignores_hidden_output_phase() { let mut seen = 0; app.accumulate_streaming_output_tokens(20, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 0); - assert_eq!(app.streaming_tps_observed_output_tokens, 0); + assert_eq!(app.streaming.streaming_total_output_tokens, 0); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 0); assert_eq!(seen, 20); - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); app.accumulate_streaming_output_tokens(60, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 40); - assert_eq!(app.streaming_tps_observed_output_tokens, 40); + assert_eq!(app.streaming.streaming_total_output_tokens, 40); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 40); assert_eq!(seen, 60); } #[test] fn test_compute_streaming_tps_uses_latest_observed_snapshot_instead_of_current_repaint_time() { let mut app = create_test_app(); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(20)); - app.streaming_tps_observed_output_tokens = 40; - app.streaming_tps_observed_elapsed = Duration::from_secs(10); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(20)); + app.streaming.streaming_tps_observed_output_tokens = 40; + app.streaming.streaming_tps_observed_elapsed = Duration::from_secs(10); let tps = app.compute_streaming_tps().expect("tps"); assert!(tps > 3.9 && tps < 4.1, "unexpected tps: {tps}"); @@ -225,12 +225,12 @@ fn test_compute_streaming_tps_does_not_decay_on_redundant_usage_snapshots() { let mut app = create_test_app(); let mut seen = 0; - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); app.accumulate_streaming_output_tokens(40, &mut seen); let initial_tps = app.compute_streaming_tps().expect("initial tps"); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(30)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(30)); app.accumulate_streaming_output_tokens(40, &mut seen); let tps = app.compute_streaming_tps().expect("tps"); @@ -249,21 +249,21 @@ fn test_compute_streaming_tps_bursty_stream_simulation_stays_constant_between_re let mut app = create_test_app(); let mut seen = 0; - app.streaming_tps_collect_output = true; + app.streaming.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); app.accumulate_streaming_output_tokens(10, &mut seen); let tps_after_first_burst = app.compute_streaming_tps().expect("tps after first burst"); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(5)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(5)); app.accumulate_streaming_output_tokens(10, &mut seen); let tps_after_idle_gap = app.compute_streaming_tps().expect("tps after idle gap"); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(6)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(6)); app.accumulate_streaming_output_tokens(30, &mut seen); let tps_after_second_burst = app.compute_streaming_tps().expect("tps after second burst"); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(9)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(9)); app.accumulate_streaming_output_tokens(30, &mut seen); let tps_after_second_idle_gap = app .compute_streaming_tps() @@ -292,48 +292,48 @@ fn test_streaming_tps_timer_resume_pause_reset_lifecycle() { let mut app = create_test_app(); assert_eq!(app.current_streaming_tps_elapsed(), Duration::ZERO); - assert!(!app.streaming_tps_collect_output); + assert!(!app.streaming.streaming_tps_collect_output); app.resume_streaming_tps(); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_some()); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_some()); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); app.pause_streaming_tps(true); - assert!(app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(2)); + assert!(app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(2)); - let elapsed_after_pause = app.streaming_tps_elapsed; + let elapsed_after_pause = app.streaming.streaming_tps_elapsed; app.pause_streaming_tps(false); - assert!(!app.streaming_tps_collect_output); - assert_eq!(app.streaming_tps_elapsed, elapsed_after_pause); + assert!(!app.streaming.streaming_tps_collect_output); + assert_eq!(app.streaming.streaming_tps_elapsed, elapsed_after_pause); - app.streaming_total_output_tokens = 42; - app.streaming_tps_observed_output_tokens = 42; - app.streaming_tps_observed_elapsed = elapsed_after_pause; + app.streaming.streaming_total_output_tokens = 42; + app.streaming.streaming_tps_observed_output_tokens = 42; + app.streaming.streaming_tps_observed_elapsed = elapsed_after_pause; app.reset_streaming_tps(); - assert_eq!(app.streaming_tps_elapsed, Duration::ZERO); - assert_eq!(app.streaming_total_output_tokens, 0); - assert_eq!(app.streaming_tps_observed_output_tokens, 0); - assert_eq!(app.streaming_tps_observed_elapsed, Duration::ZERO); - assert!(!app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); + assert_eq!(app.streaming.streaming_tps_elapsed, Duration::ZERO); + assert_eq!(app.streaming.streaming_total_output_tokens, 0); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 0); + assert_eq!(app.streaming.streaming_tps_observed_elapsed, Duration::ZERO); + assert!(!app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); } #[test] fn test_compute_streaming_tps_requires_tokens_and_minimum_elapsed() { let mut app = create_test_app(); - app.streaming_tps_observed_elapsed = Duration::from_secs(10); + app.streaming.streaming_tps_observed_elapsed = Duration::from_secs(10); assert!(app.compute_streaming_tps().is_none()); - app.streaming_tps_observed_output_tokens = 10; - app.streaming_tps_observed_elapsed = Duration::from_millis(100); + app.streaming.streaming_tps_observed_output_tokens = 10; + app.streaming.streaming_tps_observed_elapsed = Duration::from_millis(100); assert!(app.compute_streaming_tps().is_none()); - app.streaming_tps_observed_elapsed = Duration::from_millis(250); + app.streaming.streaming_tps_observed_elapsed = Duration::from_millis(250); let tps = app.compute_streaming_tps().expect("tps above threshold"); assert!(tps > 35.0 && tps <= 40.0, "unexpected tps: {tps}"); } @@ -343,16 +343,16 @@ fn test_accumulate_streaming_output_tokens_counts_provider_usage_reset_once() { let mut app = create_test_app(); let mut seen = 80; - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); app.accumulate_streaming_output_tokens(20, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 20); + assert_eq!(app.streaming.streaming_total_output_tokens, 20); assert_eq!(seen, 20); app.accumulate_streaming_output_tokens(25, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 25); - assert_eq!(app.streaming_tps_observed_output_tokens, 25); + assert_eq!(app.streaming.streaming_total_output_tokens, 25); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 25); assert_eq!(seen, 25); } @@ -361,18 +361,18 @@ fn test_streaming_tps_late_final_usage_after_pause_uses_paused_elapsed() { let mut app = create_test_app(); let mut seen = 0; - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(10)); app.pause_streaming_tps(true); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(10)); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(10)); app.accumulate_streaming_output_tokens(40, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 40); - assert_eq!(app.streaming_tps_observed_output_tokens, 40); - assert!(app.streaming_tps_observed_elapsed >= Duration::from_secs(10)); + assert_eq!(app.streaming.streaming_total_output_tokens, 40); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 40); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(10)); let tps = app.compute_streaming_tps().expect("late tps"); assert!(tps > 3.0 && tps <= 4.0, "unexpected late tps: {tps}"); } @@ -382,26 +382,26 @@ fn test_begin_kv_cache_request_stops_tps_collection_until_output_resumes() { let mut app = create_test_app(); let mut seen = 0; - app.streaming_tps_collect_output = true; - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); + app.streaming.streaming_tps_collect_output = true; + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); app.begin_kv_cache_request(&[Message::user("next")], &[], "system", "dynamic"); - assert!(!app.streaming_tps_collect_output); - assert!(app.streaming_tps_start.is_none()); - assert!(app.streaming_tps_elapsed >= Duration::from_secs(3)); + assert!(!app.streaming.streaming_tps_collect_output); + assert!(app.streaming.streaming_tps_start.is_none()); + assert!(app.streaming.streaming_tps_elapsed >= Duration::from_secs(3)); app.accumulate_streaming_output_tokens(20, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 0); + assert_eq!(app.streaming.streaming_total_output_tokens, 0); assert_eq!(seen, 20); app.resume_streaming_tps(); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); app.accumulate_streaming_output_tokens(50, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 30); - assert_eq!(app.streaming_tps_observed_output_tokens, 30); - assert!(app.streaming_tps_observed_elapsed >= Duration::from_secs(5)); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 30); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(5)); } #[test] @@ -410,20 +410,20 @@ fn test_streaming_tps_accumulates_multiple_generation_segments_excluding_paused_ let mut seen = 0; app.resume_streaming_tps(); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(2)); app.accumulate_streaming_output_tokens(10, &mut seen); app.pause_streaming_tps(true); - let elapsed_after_first_segment = app.streaming_tps_elapsed; + let elapsed_after_first_segment = app.streaming.streaming_tps_elapsed; assert!(elapsed_after_first_segment >= Duration::from_secs(2)); app.resume_streaming_tps(); - app.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); + app.streaming.streaming_tps_start = Some(Instant::now() - Duration::from_secs(3)); app.accumulate_streaming_output_tokens(30, &mut seen); - assert_eq!(app.streaming_total_output_tokens, 30); - assert_eq!(app.streaming_tps_observed_output_tokens, 30); - assert!(app.streaming_tps_observed_elapsed >= Duration::from_secs(5)); + assert_eq!(app.streaming.streaming_total_output_tokens, 30); + assert_eq!(app.streaming.streaming_tps_observed_output_tokens, 30); + assert!(app.streaming.streaming_tps_observed_elapsed >= Duration::from_secs(5)); let tps = app.compute_streaming_tps().expect("segmented tps"); assert!(tps > 5.0 && tps <= 6.0, "unexpected segmented tps: {tps}"); } diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 2e2855cf5..b8deb6c63 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -294,15 +294,11 @@ impl App { auto_scroll_paused: false, active_skill: None, is_processing: false, - streaming_text: String::new(), + streaming: StreamingProgress::default(), should_quit: false, queued_messages: Vec::new(), hidden_queued_system_messages: Vec::new(), current_turn_system_reminder: None, - streaming_input_tokens: 0, - streaming_output_tokens: 0, - streaming_cache_read_tokens: None, - streaming_cache_creation_tokens: None, upstream_provider: None, connection_type: None, status_detail: None, @@ -321,12 +317,6 @@ impl App { stream_message_ended: false, remote_resume_activity: None, pending_reload_reconnect_status: None, - streaming_tps_start: None, - streaming_tps_elapsed: Duration::ZERO, - streaming_tps_collect_output: false, - streaming_total_output_tokens: 0, - streaming_tps_observed_output_tokens: 0, - streaming_tps_observed_elapsed: Duration::ZERO, status: ProcessingStatus::default(), subagent_status: None, batch_progress: None, @@ -680,15 +670,11 @@ impl App { auto_scroll_paused: false, active_skill: None, is_processing: false, - streaming_text: String::new(), + streaming: StreamingProgress::default(), should_quit: false, queued_messages: Vec::new(), hidden_queued_system_messages: Vec::new(), current_turn_system_reminder: None, - streaming_input_tokens: 0, - streaming_output_tokens: 0, - streaming_cache_read_tokens: None, - streaming_cache_creation_tokens: None, upstream_provider: None, connection_type: None, status_detail: None, @@ -707,12 +693,6 @@ impl App { stream_message_ended: false, remote_resume_activity: None, pending_reload_reconnect_status: None, - streaming_tps_start: None, - streaming_tps_elapsed: Duration::ZERO, - streaming_tps_collect_output: false, - streaming_total_output_tokens: 0, - streaming_tps_observed_output_tokens: 0, - streaming_tps_observed_elapsed: Duration::ZERO, status: ProcessingStatus::default(), subagent_status: None, batch_progress: None, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 7f00d1534..fea900921 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -265,8 +265,8 @@ impl App { total_cost: self.total_cost, input_tokens: self.token_accounting.total_input_tokens, output_tokens: self.token_accounting.total_output_tokens, - cache_read_tokens: self.streaming_cache_read_tokens, - cache_write_tokens: self.streaming_cache_creation_tokens, + cache_read_tokens: self.streaming.streaming_cache_read_tokens, + cache_write_tokens: self.streaming.streaming_cache_creation_tokens, output_tps, available: true, }; @@ -412,7 +412,7 @@ impl crate::tui::TuiState for App { } fn streaming_text(&self) -> &str { - &self.streaming_text + &self.streaming.streaming_text } fn input(&self) -> &str { @@ -505,13 +505,13 @@ impl crate::tui::TuiState for App { } fn streaming_tokens(&self) -> (u64, u64) { - (self.streaming_input_tokens, self.streaming_output_tokens) + (self.streaming.streaming_input_tokens, self.streaming.streaming_output_tokens) } fn streaming_cache_tokens(&self) -> (Option, Option) { ( - self.streaming_cache_read_tokens, - self.streaming_cache_creation_tokens, + self.streaming.streaming_cache_read_tokens, + self.streaming.streaming_cache_creation_tokens, ) } @@ -1333,7 +1333,7 @@ impl crate::tui::TuiState for App { fn render_streaming_markdown(&self, width: usize) -> Vec> { let mut renderer = self.streaming_md_renderer.borrow_mut(); renderer.set_width(Some(width)); - renderer.update(&self.streaming_text) + renderer.update(&self.streaming.streaming_text) } fn centered_mode(&self) -> bool { diff --git a/crates/jcode-tui/src/tui/app/turn.rs b/crates/jcode-tui/src/tui/app/turn.rs index f5b9c5a8c..f0c9a6482 100644 --- a/crates/jcode-tui/src/tui/app/turn.rs +++ b/crates/jcode-tui/src/tui/app/turn.rs @@ -340,7 +340,7 @@ impl App { if let Some(chunk) = self.stream_buffer.flush() { self.append_streaming_text(&chunk); } - if !self.streaming_text.is_empty() { + if !self.streaming.streaming_text.is_empty() { let content = self.take_streaming_text(); let content = self.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { @@ -404,7 +404,7 @@ impl App { }); } // Add display message for partial response - if !self.streaming_text.is_empty() { + if !self.streaming.streaming_text.is_empty() { let content = self.take_streaming_text(); let content = self.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { @@ -604,22 +604,22 @@ impl App { } => { let mut usage_changed = false; if let Some(input) = input_tokens { - self.streaming_input_tokens = input; + self.streaming.streaming_input_tokens = input; usage_changed = true; } if let Some(output) = output_tokens { - self.streaming_output_tokens = output; + self.streaming.streaming_output_tokens = output; self.accumulate_streaming_output_tokens( output, &mut call_output_tokens_seen, ); } if cache_read_input_tokens.is_some() { - self.streaming_cache_read_tokens = cache_read_input_tokens; + self.streaming.streaming_cache_read_tokens = cache_read_input_tokens; usage_changed = true; } if cache_creation_input_tokens.is_some() { - self.streaming_cache_creation_tokens = + self.streaming.streaming_cache_creation_tokens = cache_creation_input_tokens; usage_changed = true; } @@ -630,11 +630,11 @@ impl App { } } self.broadcast_debug(crate::tui::backend::DebugEvent::TokenUsage { - input_tokens: self.streaming_input_tokens, - output_tokens: self.streaming_output_tokens, - cache_read_input_tokens: self.streaming_cache_read_tokens, + input_tokens: self.streaming.streaming_input_tokens, + output_tokens: self.streaming.streaming_output_tokens, + cache_read_input_tokens: self.streaming.streaming_cache_read_tokens, cache_creation_input_tokens: self - .streaming_cache_creation_tokens, + .streaming.streaming_cache_creation_tokens, }); } StreamEvent::ConnectionType { connection } => { @@ -675,7 +675,7 @@ impl App { let no_partial_output = text_content.is_empty() && tool_calls.is_empty() && current_tool.is_none() - && self.streaming_text.is_empty() + && self.streaming.streaming_text.is_empty() && !saw_message_end; if no_partial_output && let Some(reason) = crate::network_retry::classify_message(&message) @@ -936,7 +936,7 @@ impl App { let no_partial_output = text_content.is_empty() && tool_calls.is_empty() && current_tool.is_none() - && self.streaming_text.is_empty() + && self.streaming.streaming_text.is_empty() && !saw_message_end; if no_partial_output && let Some(reason) = crate::network_retry::classify_network_interruption(e.as_ref()) @@ -962,7 +962,7 @@ impl App { let no_partial_output = text_content.is_empty() && tool_calls.is_empty() && current_tool.is_none() - && self.streaming_text.is_empty() + && self.streaming.streaming_text.is_empty() && !saw_message_end; if no_partial_output { let plan = crate::network_retry::wait_plan(); @@ -1064,8 +1064,8 @@ impl App { } else { // Had tool calls - only display text that came AFTER the last tool // (text before each tool was already committed in ToolUseEnd handler) - if !self.streaming_text.is_empty() { - let content = self.collapse_reasoning_for_commit(self.streaming_text.clone()); + if !self.streaming.streaming_text.is_empty() { + let content = self.collapse_reasoning_for_commit(self.streaming.streaming_text.clone()); if !content.trim().is_empty() { self.push_display_message(DisplayMessage { role: "assistant".to_string(), @@ -1227,7 +1227,7 @@ impl App { if let Some(chunk) = self.stream_buffer.flush() { self.append_streaming_text(&chunk); } - if !self.streaming_text.is_empty() { + if !self.streaming.streaming_text.is_empty() { let content = self.take_streaming_text(); let content = self.collapse_reasoning_for_commit(content); if !content.trim().is_empty() { From 521dc0633a3d0cc1ee256863a7ec3443b6253043 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:43:42 -0700 Subject: [PATCH 12/41] refactor(tui): group cost + cached pricing fields into CostState --- crates/jcode-tui/src/tui/app.rs | 29 +++++++++++++------ crates/jcode-tui/src/tui/app/misc_ui.rs | 28 +++++++++--------- crates/jcode-tui/src/tui/app/state_ui.rs | 6 ++-- .../tui/app/tests/remote_events_reload_04.rs | 6 ++-- crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 12 ++------ crates/jcode-tui/src/tui/app/tui_state.rs | 2 +- 6 files changed, 43 insertions(+), 40 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 8840adbba..e94320538 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -619,6 +619,24 @@ struct StreamingProgress { streaming_tps_observed_elapsed: Duration, } +/// Accumulated session cost and cached per-model pricing. +/// +/// Grouped out of [`App`]. `total_cost` accrues across the session; the cached +/// price fields memoize the active model's pricing so they are re-resolved only +/// when `cached_price_model` no longer matches the current model. +#[derive(Clone, Debug, Default)] +struct CostState { + // Total cost in USD (for API-key providers) + total_cost: f32, + // Cached pricing (input $/1M tokens, output $/1M tokens) + cached_prompt_price: Option, + cached_completion_price: Option, + // Cached cache-read pricing ($/1M tokens), when known for the active model. + cached_cache_read_price: Option, + // Model the cached_*_price values were resolved for, so we re-resolve on switch. + cached_price_model: Option, +} + /// State for an in-progress OAuth/API-key login flow triggered by `/login`. /// TUI Application state pub struct App { @@ -658,15 +676,8 @@ pub struct App { token_accounting: TokenAccounting, // KV cache baseline tracking + per-turn miss attribution. kv_cache: KvCacheState, - // Total cost in USD (for API-key providers) - total_cost: f32, - // Cached pricing (input $/1M tokens, output $/1M tokens) - cached_prompt_price: Option, - cached_completion_price: Option, - // Cached cache-read pricing ($/1M tokens), when known for the active model. - cached_cache_read_price: Option, - // Model the cached_*_price values were resolved for, so we re-resolve on switch. - cached_price_model: Option, + // Accumulated session cost + cached per-model pricing. + cost: CostState, // Context limit tracking (for compaction warning) context_limit: u64, context_warning_shown: bool, diff --git a/crates/jcode-tui/src/tui/app/misc_ui.rs b/crates/jcode-tui/src/tui/app/misc_ui.rs index 34da93c0e..b4211756f 100644 --- a/crates/jcode-tui/src/tui/app/misc_ui.rs +++ b/crates/jcode-tui/src/tui/app/misc_ui.rs @@ -219,9 +219,9 @@ impl App { // Pricing in $/1M tokens. Anthropic resolves real per-model pricing in // refresh_cached_pricing; other providers fall back to the generic // defaults cached here. - let prompt_price = *self.cached_prompt_price.get_or_insert(15.0); - let completion_price = *self.cached_completion_price.get_or_insert(60.0); - let cache_read_price = self.cached_cache_read_price; + let prompt_price = *self.cost.cached_prompt_price.get_or_insert(15.0); + let completion_price = *self.cost.cached_completion_price.get_or_insert(60.0); + let cache_read_price = self.cost.cached_cache_read_price; let pricing = ResolvedTokenPricing { prompt_price, @@ -230,7 +230,7 @@ impl App { is_anthropic, }; - self.total_cost += pricing.cost_for_usage( + self.cost.total_cost += pricing.cost_for_usage( self.streaming.streaming_input_tokens, self.streaming.streaming_output_tokens, self.streaming.streaming_cache_read_tokens.unwrap_or(0), @@ -266,7 +266,7 @@ impl App { let Some(pricing) = self.resolve_remote_cost_pricing() else { return; }; - self.total_cost += pricing.cost_for_usage( + self.cost.total_cost += pricing.cost_for_usage( input_delta, output_delta, cache_read_delta, @@ -316,9 +316,9 @@ impl App { self.refresh_cached_pricing(&model, is_anthropic, is_openai); Some(ResolvedTokenPricing { - prompt_price: *self.cached_prompt_price.get_or_insert(15.0), - completion_price: *self.cached_completion_price.get_or_insert(60.0), - cache_read_price: self.cached_cache_read_price, + prompt_price: *self.cost.cached_prompt_price.get_or_insert(15.0), + completion_price: *self.cost.cached_completion_price.get_or_insert(60.0), + cache_read_price: self.cost.cached_cache_read_price, is_anthropic, }) } @@ -328,7 +328,7 @@ impl App { /// (input, output and cache-read) so the API-key cost figure is accurate per /// model. Re-resolves when the active model changes. fn refresh_cached_pricing(&mut self, model: &str, is_anthropic: bool, is_openai: bool) { - if self.cached_price_model.as_deref() == Some(model) { + if self.cost.cached_price_model.as_deref() == Some(model) { return; } @@ -342,16 +342,16 @@ impl App { }; if let Some(estimate) = estimate { - self.cached_prompt_price = per_mtok(estimate.input_price_per_mtok_micros); - self.cached_completion_price = per_mtok(estimate.output_price_per_mtok_micros); - self.cached_cache_read_price = per_mtok(estimate.cache_read_price_per_mtok_micros); - self.cached_price_model = Some(model.to_string()); + self.cost.cached_prompt_price = per_mtok(estimate.input_price_per_mtok_micros); + self.cost.cached_completion_price = per_mtok(estimate.output_price_per_mtok_micros); + self.cost.cached_cache_read_price = per_mtok(estimate.cache_read_price_per_mtok_micros); + self.cost.cached_price_model = Some(model.to_string()); return; } // Unknown model: leave existing defaults in place but remember the model // so we do not repeatedly attempt resolution for it. - self.cached_price_model = Some(model.to_string()); + self.cost.cached_price_model = Some(model.to_string()); } pub(super) fn compute_streaming_tps(&self) -> Option { diff --git a/crates/jcode-tui/src/tui/app/state_ui.rs b/crates/jcode-tui/src/tui/app/state_ui.rs index 839702560..15780c624 100644 --- a/crates/jcode-tui/src/tui/app/state_ui.rs +++ b/crates/jcode-tui/src/tui/app/state_ui.rs @@ -1217,16 +1217,16 @@ fn format_cache_stats(app: &App) -> String { "- client_observed_completed_output_tokens: {}", bold_count(app.token_accounting.total_output_tokens) )); - lines.push(format!("- total_cost_usd: {:.6}", app.total_cost)); + lines.push(format!("- total_cost_usd: {:.6}", app.cost.total_cost)); lines.push(format!( "- cached_prompt_price_per_1m: {}", - app.cached_prompt_price + app.cost.cached_prompt_price .map(|price| format!("{:.6}", price)) .unwrap_or_else(|| "None".to_string()) )); lines.push(format!( "- cached_completion_price_per_1m: {}", - app.cached_completion_price + app.cost.cached_completion_price .map(|price| format!("{:.6}", price)) .unwrap_or_else(|| "None".to_string()) )); diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs index 95f13793e..c07d59411 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs @@ -780,7 +780,7 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { app.update_cost_impl(); assert!( - app.total_cost > 0.0, + app.cost.total_cost > 0.0, "{runtime_provider} should accrue token cost" ); @@ -807,7 +807,7 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { app.token_accounting.total_input_tokens = 12_000; app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); - assert_eq!(app.total_cost, 0.0); + assert_eq!(app.cost.total_cost, 0.0); let data = crate::tui::TuiState::info_widget_data(&app); assert_eq!( @@ -824,7 +824,7 @@ fn test_info_widget_local_direct_api_runtime_shows_cost_based_usage() { app.token_accounting.total_input_tokens = 12_000; app.token_accounting.total_output_tokens = 3_400; app.update_cost_impl(); - assert_eq!(app.total_cost, 0.0); + assert_eq!(app.cost.total_cost, 0.0); let data = crate::tui::TuiState::info_widget_data(&app); assert_eq!( diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index b8deb6c63..eacbf9db9 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -304,11 +304,7 @@ impl App { status_detail: None, token_accounting: TokenAccounting::default(), kv_cache: KvCacheState::default(), - total_cost: 0.0, - cached_prompt_price: None, - cached_completion_price: None, - cached_cache_read_price: None, - cached_price_model: None, + cost: CostState::default(), context_limit, context_warning_shown: false, context_info: crate::prompt::ContextInfo::default(), @@ -680,11 +676,7 @@ impl App { status_detail: None, token_accounting: TokenAccounting::default(), kv_cache: KvCacheState::default(), - total_cost: 0.0, - cached_prompt_price: None, - cached_completion_price: None, - cached_cache_read_price: None, - cached_price_model: None, + cost: CostState::default(), context_limit, context_warning_shown: false, context_info, diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index fea900921..307fd6739 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -262,7 +262,7 @@ impl App { seven_day_resets_at: None, spark: None, spark_resets_at: None, - total_cost: self.total_cost, + total_cost: self.cost.total_cost, input_tokens: self.token_accounting.total_input_tokens, output_tokens: self.token_accounting.total_output_tokens, cache_read_tokens: self.streaming.streaming_cache_read_tokens, From a03f17b354179836a9812311351773e67a47bcba Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:35:09 -0700 Subject: [PATCH 13/41] refactor(tui): move workspace_client process-global into App-owned state Replace static WORKSPACE_STATE Mutex> with a WorkspaceClientState field on App. Convert the module's free functions to methods on the struct. Each client instance now owns its workspace map instead of sharing one process-global, eliminating cross-instance state leakage. Behavior-preserving; updates all ~7 non-test callers plus tests. --- crates/jcode-tui/src/tui/app.rs | 3 + .../src/tui/app/inline_interactive.rs | 2 +- crates/jcode-tui/src/tui/app/remote.rs | 2 +- .../src/tui/app/remote/server_events.rs | 4 +- .../jcode-tui/src/tui/app/remote/workspace.rs | 22 +- .../app/tests/commands_accounts_01/part_01.rs | 2 +- .../app/tests/state_model_poke_01/part_01.rs | 6 +- crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 2 + crates/jcode-tui/src/tui/app/tui_state.rs | 8 +- crates/jcode-tui/src/tui/workspace_client.rs | 319 +++++++----------- 10 files changed, 159 insertions(+), 211 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index e94320538..5343f3bc0 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -1180,6 +1180,9 @@ pub struct App { productivity_refreshing: bool, /// Last time the passive overnight progress card polled its run files. last_overnight_card_refresh: Option, + /// Per-client Niri-style workspace navigation state. Previously a process + /// global; now owned per App instance. + workspace_client: super::workspace_client::WorkspaceClientState, } /// Inert provider used by runtime modes whose output is supplied by another source. diff --git a/crates/jcode-tui/src/tui/app/inline_interactive.rs b/crates/jcode-tui/src/tui/app/inline_interactive.rs index 94ff6f947..4024d5191 100644 --- a/crates/jcode-tui/src/tui/app/inline_interactive.rs +++ b/crates/jcode-tui/src/tui/app/inline_interactive.rs @@ -1955,7 +1955,7 @@ impl App { name ))); } - crate::tui::workspace_client::queue_resume_session(session_id); + self.workspace_client.queue_resume_session(session_id); self.session_picker_overlay = None; self.session_picker_mode = SessionPickerMode::Resume; self.set_status_notice(format!("Switching → {}", name)); diff --git a/crates/jcode-tui/src/tui/app/remote.rs b/crates/jcode-tui/src/tui/app/remote.rs index d9ac4f7f8..740e31f8b 100644 --- a/crates/jcode-tui/src/tui/app/remote.rs +++ b/crates/jcode-tui/src/tui/app/remote.rs @@ -123,7 +123,7 @@ pub(super) async fn handle_tick(app: &mut App, remote: &mut RemoteConnection) -> } } - if let Some(target_session) = crate::tui::workspace_client::take_pending_resume_session() { + if let Some(target_session) = app.workspace_client.take_pending_resume_session() { match remote.resume_session(&target_session).await { Ok(()) => { let label = crate::id::extract_session_name(&target_session) diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index d8a425583..f31eeacd0 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -1166,7 +1166,7 @@ pub(in crate::tui::app) fn handle_server_event( totals.cache_creation_input_tokens )); } - crate::tui::workspace_client::sync_after_history(&session_id, &app.remote_sessions); + app.workspace_client.sync_after_history(&session_id, &app.remote_sessions); if server_has_update == Some(true) && !app.pending_server_reload { app.pending_server_reload = true; @@ -1870,7 +1870,7 @@ pub(in crate::tui::app) fn handle_server_event( new_session_name, .. } => { - if crate::tui::workspace_client::handle_split_response(&new_session_id) { + if app.workspace_client.handle_split_response(&new_session_id) { finish_remote_split_launch(app); app.pending_split_request = false; app.pending_split_startup_message = None; diff --git a/crates/jcode-tui/src/tui/app/remote/workspace.rs b/crates/jcode-tui/src/tui/app/remote/workspace.rs index 9305700fb..62c6eea5b 100644 --- a/crates/jcode-tui/src/tui/app/remote/workspace.rs +++ b/crates/jcode-tui/src/tui/app/remote/workspace.rs @@ -10,7 +10,7 @@ pub(super) async fn handle_workspace_navigation_key( modifiers: KeyModifiers, remote: &mut RemoteConnection, ) -> Result { - if !crate::tui::workspace_client::is_enabled() { + if !app.workspace_client.is_enabled() { return Ok(false); } @@ -19,10 +19,10 @@ pub(super) async fn handle_workspace_navigation_key( }; let target = match direction { - WorkspaceNavigationDirection::Left => crate::tui::workspace_client::navigate_left(), - WorkspaceNavigationDirection::Right => crate::tui::workspace_client::navigate_right(), - WorkspaceNavigationDirection::Up => crate::tui::workspace_client::navigate_up(), - WorkspaceNavigationDirection::Down => crate::tui::workspace_client::navigate_down(), + WorkspaceNavigationDirection::Left => app.workspace_client.navigate_left(), + WorkspaceNavigationDirection::Right => app.workspace_client.navigate_right(), + WorkspaceNavigationDirection::Up => app.workspace_client.navigate_up(), + WorkspaceNavigationDirection::Down => app.workspace_client.navigate_down(), }; if app.is_processing { @@ -60,20 +60,20 @@ pub(super) async fn handle_workspace_command( match trimmed { "/workspace" | "/workspace status" => { app.push_display_message(DisplayMessage::system( - crate::tui::workspace_client::status_summary(), + app.workspace_client.status_summary(), )); return Ok(true); } "/workspace on" | "/workspace import" => { - crate::tui::workspace_client::enable(current_session, &app.remote_sessions); + app.workspace_client.enable(current_session, &app.remote_sessions); app.set_status_notice("Workspace mode enabled"); app.push_display_message(DisplayMessage::system( - crate::tui::workspace_client::status_summary(), + app.workspace_client.status_summary(), )); return Ok(true); } "/workspace off" => { - crate::tui::workspace_client::disable(); + app.workspace_client.disable(); app.set_status_notice("Workspace mode disabled"); app.push_display_message(DisplayMessage::system("Workspace mode: off".to_string())); return Ok(true); @@ -91,8 +91,8 @@ pub(super) async fn handle_workspace_command( }; if let Some(target) = target { - crate::tui::workspace_client::enable(current_session, &app.remote_sessions); - crate::tui::workspace_client::queue_split_target(target); + app.workspace_client.enable(current_session, &app.remote_sessions); + app.workspace_client.queue_split_target(target); app.pending_split_label = Some("Workspace".to_string()); if app.is_processing { app.pending_split_request = true; diff --git a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs index 95433291a..4d4378a4e 100644 --- a/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/commands_accounts_01/part_01.rs @@ -99,7 +99,7 @@ fn session_picker_enter_queues_current_terminal_resume_and_closes_overlay() { assert!(app.session_picker_overlay.is_none()); assert_eq!( - crate::tui::workspace_client::take_pending_resume_session().as_deref(), + app.workspace_client.take_pending_resume_session().as_deref(), Some("session_here_123") ); } diff --git a/crates/jcode-tui/src/tui/app/tests/state_model_poke_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/state_model_poke_01/part_01.rs index 4f53b30bf..92afc36a2 100644 --- a/crates/jcode-tui/src/tui/app/tests/state_model_poke_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/state_model_poke_01/part_01.rs @@ -911,9 +911,9 @@ fn test_pinned_diagram_not_shown_when_terminal_too_narrow() { #[test] fn test_workspace_info_widget_appears_in_visual_debug_frame_when_enabled() { let _render_lock = scroll_render_test_lock(); - crate::tui::workspace_client::reset_for_tests(); let mut app = create_test_app(); + app.workspace_client.reset_for_tests(); app.centered = true; app.display_messages = vec![ DisplayMessage::system("Workspace widget render test".to_string()), @@ -922,7 +922,7 @@ fn test_workspace_info_widget_appears_in_visual_debug_frame_when_enabled() { app.bump_display_messages_version(); let current_session = app.session.id.clone(); - crate::tui::workspace_client::enable( + app.workspace_client.enable( Some(current_session.as_str()), &[current_session.clone(), "workspace_peer".to_string()], ); @@ -963,7 +963,7 @@ fn test_workspace_info_widget_appears_in_visual_debug_frame_when_enabled() { ); crate::tui::visual_debug::disable(); - crate::tui::workspace_client::reset_for_tests(); + app.workspace_client.reset_for_tests(); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index eacbf9db9..a9cefc932 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -560,6 +560,7 @@ impl App { usage_report_refreshing: false, productivity_refreshing: false, last_overnight_card_refresh: None, + workspace_client: crate::tui::workspace_client::WorkspaceClientState::default(), }; for notice in app.provider.drain_startup_notices() { @@ -932,6 +933,7 @@ impl App { usage_report_refreshing: false, productivity_refreshing: false, last_overnight_card_refresh: None, + workspace_client: crate::tui::workspace_client::WorkspaceClientState::default(), }; for notice in app.provider.drain_startup_notices() { diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 307fd6739..e436a1fec 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -1230,13 +1230,13 @@ impl crate::tui::TuiState for App { Vec::new() }; - let workspace_rows = if crate::tui::workspace_client::is_enabled() { + let workspace_rows = if self.workspace_client.is_enabled() { let session_id = if self.is_remote { self.remote_session_id.as_deref() } else { Some(self.session.id.as_str()) }; - crate::tui::workspace_client::visible_rows(5, session_id, self.is_processing) + self.workspace_client.visible_rows(5, session_id, self.is_processing) } else { Vec::new() }; @@ -1314,7 +1314,7 @@ impl crate::tui::TuiState for App { } fn workspace_mode_enabled(&self) -> bool { - crate::tui::workspace_client::is_enabled() + self.workspace_client.is_enabled() } fn workspace_map_rows(&self) -> Vec { @@ -1323,7 +1323,7 @@ impl crate::tui::TuiState for App { } else { Some(self.session.id.as_str()) }; - crate::tui::workspace_client::visible_rows(5, session_id, self.is_processing) + self.workspace_client.visible_rows(5, session_id, self.is_processing) } fn workspace_animation_tick(&self) -> u64 { diff --git a/crates/jcode-tui/src/tui/workspace_client.rs b/crates/jcode-tui/src/tui/workspace_client.rs index 21fb31511..5eb3f638c 100644 --- a/crates/jcode-tui/src/tui/workspace_client.rs +++ b/crates/jcode-tui/src/tui/workspace_client.rs @@ -2,7 +2,6 @@ use crate::session::{Session, SessionStatus}; use crate::tui::workspace_map::{ VisibleWorkspaceRow, WorkspaceMapModel, WorkspaceSessionTile, WorkspaceSessionVisualState, }; -use std::sync::Mutex; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WorkspaceSplitTarget { @@ -11,8 +10,13 @@ pub enum WorkspaceSplitTarget { Down, } +/// Per-client workspace navigation state. +/// +/// Previously stored in a process-global `Mutex>`; now owned by +/// [`crate::tui::app::App`] so each client instance carries its own workspace +/// map instead of sharing one across the process. #[derive(Debug, Clone, Default)] -struct WorkspaceClientState { +pub(crate) struct WorkspaceClientState { enabled: bool, map: WorkspaceMapModel, imported_server_sessions: bool, @@ -20,180 +24,142 @@ struct WorkspaceClientState { pending_resume_session: Option, } -static WORKSPACE_STATE: Mutex> = Mutex::new(None); - -fn with_state(f: impl FnOnce(&mut WorkspaceClientState) -> R) -> R { - let mut guard = WORKSPACE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - let state = guard.get_or_insert_with(WorkspaceClientState::default); - f(state) -} - -pub fn is_enabled() -> bool { - with_state(|state| state.enabled) -} +impl WorkspaceClientState { + pub(crate) fn is_enabled(&self) -> bool { + self.enabled + } -pub fn enable(current_session_id: Option<&str>, all_sessions: &[String]) { - with_state(|state| { - state.enabled = true; - if state.map.is_empty() { - import_initial_row(state, current_session_id, all_sessions); + pub(crate) fn enable(&mut self, current_session_id: Option<&str>, all_sessions: &[String]) { + self.enabled = true; + if self.map.is_empty() { + self.import_initial_row(current_session_id, all_sessions); } else if let Some(session_id) = current_session_id { - let _ = state.map.focus_session_by_id(session_id); + let _ = self.map.focus_session_by_id(session_id); } - }); -} + } -pub fn disable() { - with_state(|state| { - state.enabled = false; - state.pending_split_target = None; - state.pending_resume_session = None; - }); -} + pub(crate) fn disable(&mut self) { + self.enabled = false; + self.pending_split_target = None; + self.pending_resume_session = None; + } -#[cfg(test)] -pub(crate) fn reset_for_tests() { - let mut guard = WORKSPACE_STATE.lock().unwrap_or_else(|e| e.into_inner()); - *guard = None; -} + #[cfg(test)] + pub(crate) fn reset_for_tests(&mut self) { + *self = Self::default(); + } -pub fn status_summary() -> String { - with_state(|state| { - if !state.enabled { + pub(crate) fn status_summary(&self) -> String { + if !self.enabled { return "Workspace mode: off".to_string(); } - let rows = state.map.visible_rows(5); - let populated = state.map.populated_workspaces().len(); + let rows = self.map.visible_rows(5); + let populated = self.map.populated_workspaces().len(); let total_sessions: usize = rows.iter().map(|row| row.sessions.len()).sum(); format!( "Workspace mode: on\nCurrent workspace: {}\nVisible rows: {}\nPopulated workspaces: {}\nMapped sessions: {}", - state.map.current_workspace(), + self.map.current_workspace(), rows.len(), populated, total_sessions ) - }) -} + } -pub fn sync_after_history(current_session_id: &str, all_sessions: &[String]) { - with_state(|state| { - if !state.enabled { + pub(crate) fn sync_after_history(&mut self, current_session_id: &str, all_sessions: &[String]) { + if !self.enabled { return; } - if state.map.is_empty() { - import_initial_row(state, Some(current_session_id), all_sessions); + if self.map.is_empty() { + self.import_initial_row(Some(current_session_id), all_sessions); return; } - if state.map.focus_session_by_id(current_session_id) { + if self.map.focus_session_by_id(current_session_id) { return; } let tile = WorkspaceSessionTile::new(current_session_id.to_string()); - let _ = state.map.add_session_to_current_workspace(tile); - }); -} + let _ = self.map.add_session_to_current_workspace(tile); + } -pub fn queue_split_target(target: WorkspaceSplitTarget) { - with_state(|state| { - state.enabled = true; - state.pending_split_target = Some(target); - }); -} + pub(crate) fn queue_split_target(&mut self, target: WorkspaceSplitTarget) { + self.enabled = true; + self.pending_split_target = Some(target); + } -pub fn take_pending_resume_session() -> Option { - with_state(|state| state.pending_resume_session.take()) -} + pub(crate) fn take_pending_resume_session(&mut self) -> Option { + self.pending_resume_session.take() + } -pub fn queue_resume_session(session_id: String) { - with_state(|state| { - state.pending_resume_session = Some(session_id); - }); -} + pub(crate) fn queue_resume_session(&mut self, session_id: String) { + self.pending_resume_session = Some(session_id); + } -pub fn handle_split_response(new_session_id: &str) -> bool { - with_state(|state| { - if !state.enabled || state.pending_split_target.is_none() { - state.pending_split_target = None; + pub(crate) fn handle_split_response(&mut self, new_session_id: &str) -> bool { + if !self.enabled || self.pending_split_target.is_none() { + self.pending_split_target = None; return false; } - let target = state + let target = self .pending_split_target .take() .unwrap_or(WorkspaceSplitTarget::Right); let target_workspace = match target { - WorkspaceSplitTarget::Right => state.map.current_workspace(), - WorkspaceSplitTarget::Up => state.map.current_workspace() + 1, - WorkspaceSplitTarget::Down => state.map.current_workspace() - 1, + WorkspaceSplitTarget::Right => self.map.current_workspace(), + WorkspaceSplitTarget::Up => self.map.current_workspace() + 1, + WorkspaceSplitTarget::Down => self.map.current_workspace() - 1, }; - let _ = state.map.insert_session_in_workspace( + let _ = self.map.insert_session_in_workspace( target_workspace, WorkspaceSessionTile::new(new_session_id.to_string()), ); - let _ = state.map.focus_session_by_id(new_session_id); - state.pending_resume_session = Some(new_session_id.to_string()); + let _ = self.map.focus_session_by_id(new_session_id); + self.pending_resume_session = Some(new_session_id.to_string()); true - }) -} + } -pub fn navigate_left() -> Option { - with_state(|state| { - if !state.enabled || !state.map.move_left() { + pub(crate) fn navigate_left(&mut self) -> Option { + if !self.enabled || !self.map.move_left() { return None; } - state - .map - .current_focused_session_id() - .map(ToString::to_string) - }) -} + self.map.current_focused_session_id().map(ToString::to_string) + } -pub fn navigate_right() -> Option { - with_state(|state| { - if !state.enabled || !state.map.move_right() { + pub(crate) fn navigate_right(&mut self) -> Option { + if !self.enabled || !self.map.move_right() { return None; } - state - .map - .current_focused_session_id() - .map(ToString::to_string) - }) -} + self.map.current_focused_session_id().map(ToString::to_string) + } -pub fn navigate_up() -> Option { - with_state(|state| { - if !state.enabled { + pub(crate) fn navigate_up(&mut self) -> Option { + if !self.enabled { return None; } - let target_workspace = state.map.nearest_populated_workspace_above()?; - state.map.set_current_workspace(target_workspace); - state - .map + let target_workspace = self.map.nearest_populated_workspace_above()?; + self.map.set_current_workspace(target_workspace); + self.map .focused_session_in_workspace(target_workspace) .map(ToString::to_string) - }) -} + } -pub fn navigate_down() -> Option { - with_state(|state| { - if !state.enabled { + pub(crate) fn navigate_down(&mut self) -> Option { + if !self.enabled { return None; } - let target_workspace = state.map.nearest_populated_workspace_below()?; - state.map.set_current_workspace(target_workspace); - state - .map + let target_workspace = self.map.nearest_populated_workspace_below()?; + self.map.set_current_workspace(target_workspace); + self.map .focused_session_in_workspace(target_workspace) .map(ToString::to_string) - }) -} + } -pub fn visible_rows( - max_rows: usize, - current_session_id: Option<&str>, - current_session_running: bool, -) -> Vec { - with_state(|state| { - let mut rows = if state.enabled { - state.map.visible_rows(max_rows) + pub(crate) fn visible_rows( + &self, + max_rows: usize, + current_session_id: Option<&str>, + current_session_running: bool, + ) -> Vec { + let mut rows = if self.enabled { + self.map.visible_rows(max_rows) } else { Vec::new() }; @@ -207,41 +173,37 @@ pub fn visible_rows( } } rows - }) -} + } -fn import_initial_row( - state: &mut WorkspaceClientState, - current_session_id: Option<&str>, - all_sessions: &[String], -) { - let sessions: Vec = if all_sessions.is_empty() { - current_session_id - .map(|id| vec![id.to_string()]) - .unwrap_or_default() - } else { - all_sessions.to_vec() - }; + fn import_initial_row(&mut self, current_session_id: Option<&str>, all_sessions: &[String]) { + let sessions: Vec = if all_sessions.is_empty() { + current_session_id + .map(|id| vec![id.to_string()]) + .unwrap_or_default() + } else { + all_sessions.to_vec() + }; - if let Some(current) = current_session_id - && !state.map.is_empty() - && state.map.locate_session(current).is_some() - { - let _ = state.map.focus_session_by_id(current); - return; - } + if let Some(current) = current_session_id + && !self.map.is_empty() + && self.map.locate_session(current).is_some() + { + let _ = self.map.focus_session_by_id(current); + return; + } - let focused_index = current_session_id - .and_then(|current| sessions.iter().position(|session_id| session_id == current)) - .or_else(|| (!sessions.is_empty()).then_some(0)); + let focused_index = current_session_id + .and_then(|current| sessions.iter().position(|session_id| session_id == current)) + .or_else(|| (!sessions.is_empty()).then_some(0)); - let tiles = sessions - .into_iter() - .map(WorkspaceSessionTile::new) - .collect::>(); - state.map.set_row_sessions(0, tiles, focused_index); - state.map.set_current_workspace(0); - state.imported_server_sessions = true; + let tiles = sessions + .into_iter() + .map(WorkspaceSessionTile::new) + .collect::>(); + self.map.set_row_sessions(0, tiles, focused_index); + self.map.set_current_workspace(0); + self.imported_server_sessions = true; + } } fn derive_visual_state( @@ -271,33 +233,17 @@ fn derive_visual_state( #[cfg(test)] mod tests { - use super::{ - WorkspaceSplitTarget, enable, handle_split_response, is_enabled, navigate_right, - queue_split_target, reset_for_tests, status_summary, sync_after_history, visible_rows, - }; - use std::sync::{Mutex, OnceLock}; - - fn test_lock() -> std::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .expect("workspace test lock") - } - - fn reset() { - reset_for_tests(); - } + use super::{WorkspaceClientState, WorkspaceSplitTarget}; #[test] fn enabling_imports_initial_sessions() { - let _guard = test_lock(); - reset(); - enable( + let mut state = WorkspaceClientState::default(); + state.enable( Some("session_a"), &["session_a".to_string(), "session_b".to_string()], ); - assert!(is_enabled()); - let rows = visible_rows(3, Some("session_a"), false); + assert!(state.is_enabled()); + let rows = state.visible_rows(3, Some("session_a"), false); assert_eq!(rows.len(), 1); assert_eq!(rows[0].sessions.len(), 2); assert_eq!(rows[0].focused_index, Some(0)); @@ -305,28 +251,26 @@ mod tests { #[test] fn horizontal_navigation_returns_new_target() { - let _guard = test_lock(); - reset(); - enable( + let mut state = WorkspaceClientState::default(); + state.enable( Some("session_a"), &["session_a".to_string(), "session_b".to_string()], ); - let next = navigate_right(); + let next = state.navigate_right(); assert_eq!(next.as_deref(), Some("session_b")); } #[test] fn split_response_in_workspace_targets_new_session() { - let _guard = test_lock(); - reset(); - enable(Some("session_a"), &["session_a".to_string()]); - queue_split_target(WorkspaceSplitTarget::Right); - assert!(handle_split_response("session_child")); - sync_after_history( + let mut state = WorkspaceClientState::default(); + state.enable(Some("session_a"), &["session_a".to_string()]); + state.queue_split_target(WorkspaceSplitTarget::Right); + assert!(state.handle_split_response("session_child")); + state.sync_after_history( "session_child", &["session_a".to_string(), "session_child".to_string()], ); - let rows = visible_rows(3, Some("session_child"), false); + let rows = state.visible_rows(3, Some("session_child"), false); assert!( rows[0] .sessions @@ -338,10 +282,9 @@ mod tests { #[test] fn status_summary_reports_enabled_state() { - let _guard = test_lock(); - reset(); - enable(Some("session_a"), &["session_a".to_string()]); - let summary = status_summary(); + let mut state = WorkspaceClientState::default(); + state.enable(Some("session_a"), &["session_a".to_string()]); + let summary = state.status_summary(); assert!(summary.contains("Workspace mode: on")); } } From 4f0ae5b858d51fdc187809a9db8adda7ebd458ef Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:07:00 -0700 Subject: [PATCH 14/41] fix(tui): apply field renames to cache-write cost test from merge The merged-in cache-write cost test (master) used pre-rename App field names; route them through the StreamingProgress/CostState sub-structs. --- .../tui/app/tests/remote_events_reload_04.rs | 24 +++++++++---------- .../tui/app/tests/scroll_copy_02/part_01.rs | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs index c07d59411..53346b915 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_04.rs @@ -864,10 +864,10 @@ fn test_anthropic_api_cost_accounts_for_split_cache_tokens() { // A representative cold turn: most of the prompt is freshly written to cache, // a little is read back, and only a small uncached remainder is fresh input. - app.streaming_input_tokens = 1_000; // uncached fresh input - app.streaming_cache_read_tokens = Some(40_000); // served from cache - app.streaming_cache_creation_tokens = Some(100_000); // written to cache (premium) - app.streaming_output_tokens = 2_000; + app.streaming.streaming_input_tokens = 1_000; // uncached fresh input + app.streaming.streaming_cache_read_tokens = Some(40_000); // served from cache + app.streaming.streaming_cache_creation_tokens = Some(100_000); // written to cache (premium) + app.streaming.streaming_output_tokens = 2_000; app.update_cost_impl(); // Expected: @@ -878,9 +878,9 @@ fn test_anthropic_api_cost_accounts_for_split_cache_tokens() { // total = $0.645 let expected = 0.003 + 0.030 + 0.012 + 0.600; assert!( - (app.total_cost - expected).abs() < 1e-4, + (app.cost.total_cost - expected).abs() < 1e-4, "anthropic split-accounting cost should be ~${expected:.4}, got ${:.4}", - app.total_cost + app.cost.total_cost ); if let Some(value) = saved_runtime { @@ -925,12 +925,12 @@ fn test_remote_anthropic_api_key_accrues_cost_from_token_usage() { // + write 100_000 * ($3 * 2x) = $0.645 let expected = 0.003 + 0.030 + 0.012 + 0.600; assert!( - (app.total_cost - expected).abs() < 1e-4, + (app.cost.total_cost - expected).abs() < 1e-4, "remote anthropic api-key cost should be ~${expected:.4}, got ${:.4}", - app.total_cost + app.cost.total_cost ); - assert_eq!(app.total_input_tokens, 1_000); - assert_eq!(app.total_output_tokens, 2_000); + assert_eq!(app.token_accounting.total_input_tokens, 1_000); + assert_eq!(app.token_accounting.total_output_tokens, 2_000); // OAuth subscription sessions are not metered per token; cost stays $0. let mut oauth_app = create_test_app(); @@ -948,8 +948,8 @@ fn test_remote_anthropic_api_key_accrues_cost_from_token_usage() { }, &mut remote, ); - assert_eq!(oauth_app.total_cost, 0.0); - assert_eq!(oauth_app.total_input_tokens, 1_000); + assert_eq!(oauth_app.cost.total_cost, 0.0); + assert_eq!(oauth_app.token_accounting.total_input_tokens, 1_000); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs index 2b22ea4f4..924bdad76 100644 --- a/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/scroll_copy_02/part_01.rs @@ -1041,7 +1041,7 @@ fn test_copy_selection_drag_to_bottom_edge_when_pinned_does_not_snap_or_autoscro app.scroll_offset = 0; app.auto_scroll_paused = false; app.is_processing = false; - app.streaming_text.clear(); + app.streaming.streaming_text.clear(); app.status = ProcessingStatus::Idle; let backend = ratatui::backend::TestBackend::new(60, 16); @@ -1150,7 +1150,7 @@ fn test_copy_selection_drag_below_last_line_fully_selects_last_line() { app.scroll_offset = 0; app.auto_scroll_paused = false; app.is_processing = false; - app.streaming_text.clear(); + app.streaming.streaming_text.clear(); app.status = ProcessingStatus::Idle; // Tall terminal so there is empty space below the content-sized chat pane. From 88f935196004dc40954e58b0e94347ba6457a074 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:27:06 -0700 Subject: [PATCH 15/41] docs(tui): TuiState decomposition plan + section the 114-method trait Add docs/TUISTATE_TRAIT_DECOMPOSITION.md categorizing all 114 TuiState methods into 15 domains with an incremental, low-conflict split plan, and annotate the trait definition with matching section headers. Behavior-neutral (comments only); compiles clean. --- crates/jcode-tui/src/tui/mod.rs | 28 +++++ docs/TUISTATE_TRAIT_DECOMPOSITION.md | 156 +++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 docs/TUISTATE_TRAIT_DECOMPOSITION.md diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index 5cf53e0b3..90a8a6bec 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -112,7 +112,15 @@ pub fn disable_keyboard_enhancement() { } /// Trait for TUI state consumed by the shared renderer. +/// +/// This is a wide (114-method) presentation interface: the read-only surface the +/// renderer needs from `App`. The methods are grouped into the domain sections +/// below (transcript, input, scroll, stream/status, provider, session/server, +/// workspace, diagram pane, diff pane, side panel, inline, overlay, copy +/// selection, onboarding, misc). See `docs/TUISTATE_TRAIT_DECOMPOSITION.md` for +/// the incremental plan to split these into composable sub-traits. pub trait TuiState { + // ---- Transcript ---- fn display_messages(&self) -> &[DisplayMessage]; fn display_user_message_count(&self) -> usize; /// Number of user prompts hidden before the first visible message because of @@ -125,6 +133,8 @@ pub trait TuiState { /// Version counter for display_messages (monotonic, increments on mutation) fn display_messages_version(&self) -> u64; fn streaming_text(&self) -> &str; + + // ---- Input ---- fn input(&self) -> &str; fn cursor_pos(&self) -> usize; fn is_processing(&self) -> bool; @@ -132,6 +142,8 @@ pub trait TuiState { fn interleave_message(&self) -> Option<&str>; /// Messages sent as soft interrupt but not yet injected (shown in queue preview) fn pending_soft_interrupts(&self) -> &[String]; + + // ---- Scroll ---- fn scroll_offset(&self) -> usize; /// Whether auto-scroll to bottom is paused (user scrolled up during streaming) fn auto_scroll_paused(&self) -> bool; @@ -148,6 +160,8 @@ pub trait TuiState { fn copy_selection_edge_autoscroll_active(&self) -> bool { false } + + // ---- Provider ---- fn provider_name(&self) -> String; fn provider_model(&self) -> String; /// Upstream provider (e.g., which provider OpenRouter routed to) @@ -158,6 +172,8 @@ pub trait TuiState { fn status_detail(&self) -> Option; fn mcp_servers(&self) -> Vec<(String, usize)>; fn available_skills(&self) -> Vec; + + // ---- Stream / status ---- fn streaming_tokens(&self) -> (u64, u64); fn streaming_cache_tokens(&self) -> (Option, Option); /// Output tokens per second during streaming (for status bar) @@ -186,6 +202,8 @@ pub trait TuiState { 0 } /// Whether running in remote (client-server) mode + + // ---- Session / server ---- fn is_remote_mode(&self) -> bool; /// Whether running in canary/self-dev mode fn is_canary(&self) -> bool; @@ -251,6 +269,8 @@ pub trait TuiState { /// Get info widget data (todos, client count, etc.) fn info_widget_data(&self) -> info_widget::InfoWidgetData; /// Whether workspace mode is enabled for this client. + + // ---- Workspace ---- fn workspace_mode_enabled(&self) -> bool { false } @@ -272,6 +292,7 @@ pub trait TuiState { /// Update cost calculation based on token usage (for API-key providers) fn update_cost(&mut self); /// Diagram display mode (none/margin/pinned) + // ---- Diagram pane ---- fn diagram_mode(&self) -> crate::config::DiagramDisplayMode; /// Whether the diagram pane is focused (pinned mode) fn diagram_focus(&self) -> bool; @@ -292,6 +313,7 @@ pub trait TuiState { /// Diagram zoom percentage (100 = normal) fn diagram_zoom(&self) -> u8; /// Scroll offset for pinned diff pane (line index) + // ---- Diff pane ---- fn diff_pane_scroll(&self) -> usize; /// Horizontal pan offset for the shared right pane (side-panel diagrams) fn diff_pane_scroll_x(&self) -> i32; @@ -300,6 +322,7 @@ pub trait TuiState { /// Whether the pinned diff pane is focused fn diff_pane_focus(&self) -> bool; /// Session-scoped side panel state managed by the side_panel tool + // ---- Side panel ---- fn side_panel(&self) -> &crate::side_panel::SidePanelSnapshot; /// Whether to pin read images to a side pane fn pin_images(&self) -> bool; @@ -314,6 +337,7 @@ pub trait TuiState { /// Whether to wrap lines in the pinned diff pane fn diff_line_wrap(&self) -> bool; /// Interactive inline UI state (picker-like flows shown above input) + // ---- Inline ---- fn inline_interactive_state(&self) -> Option<&InlineInteractiveState>; /// Passive inline UI state (informational views shown above input) fn inline_view_state(&self) -> Option<&InlineViewState> { @@ -326,6 +350,7 @@ pub trait TuiState { .or_else(|| self.inline_view_state().map(InlineUiStateRef::View)) } /// Changelog overlay scroll offset (None = not showing) + // ---- Overlay ---- fn changelog_scroll(&self) -> Option; /// Help overlay scroll offset (None = not showing) fn help_scroll(&self) -> Option; @@ -342,10 +367,12 @@ pub trait TuiState { /// Usage overlay for /usage command fn usage_overlay(&self) -> Option<&std::cell::RefCell>; /// Working directory for this session + // ---- Misc ---- fn working_dir(&self) -> Option; /// Monotonic clock for viewport animations fn now_millis(&self) -> u64; /// UI state for live copy badge highlighting / feedback + // ---- Copy selection ---- fn copy_badge_ui(&self) -> crate::tui::CopyBadgeUiState; /// Whether modal in-app copy selection mode is active. fn copy_selection_mode(&self) -> bool; @@ -354,6 +381,7 @@ pub trait TuiState { /// Persistent status for in-app copy selection mode. fn copy_selection_status(&self) -> Option; /// Whether the first-run onboarding empty state is being previewed in this session. + // ---- Onboarding ---- fn onboarding_preview_mode(&self) -> bool { false } diff --git a/docs/TUISTATE_TRAIT_DECOMPOSITION.md b/docs/TUISTATE_TRAIT_DECOMPOSITION.md new file mode 100644 index 000000000..89a41c14d --- /dev/null +++ b/docs/TUISTATE_TRAIT_DECOMPOSITION.md @@ -0,0 +1,156 @@ +# TuiState Trait Decomposition Plan + +Status: Analysis + proposed plan + +This document audits the `TuiState` trait (`crates/jcode-tui/src/tui/mod.rs`) and +proposes a safe, incremental decomposition. It is the Phase 1.5 follow-on to the +`App` god-object decomposition (see `CLIENT_CORE_PRESENTATION_SPLIT_PLAN.md`). + +## Current state + +- `pub trait TuiState` exposes **114 methods**. +- Implementors: 2 (`App` in `tui/app/tui_state.rs`, and `TestState` in + `tui/ui_tests/mod.rs`). +- Consumers: ~95 usages across 29 files, almost all as `&dyn TuiState` (50 + render-function signatures take `app: &dyn TuiState`). + +It is the presentation-layer counterpart to the `App` god-object: a single wide +interface that couples every render module to the entire client surface. + +## Why a naive sub-trait split has limited value + +Two structural facts constrain the refactor: + +1. **`App` implements the whole surface regardless.** Splitting `TuiState` into + `TuiTranscriptState + TuiInputState + ...` does not reduce what `App` must + implement, and (because the trait is presentation-only data access) it does + not change crate-level compile coupling. The win is intent/navigability, not + decoupling of `App`. + +2. **`&dyn TuiState` does not compose.** Render functions take trait objects. + Rust has no stable `&dyn (A + B)`, so any consumer that needs methods from + more than one domain must take a supertrait that re-aggregates them. The two + central renderers (`ui.rs`, `ui_viewport.rs`) use methods from nearly every + domain, so they would keep the full supertrait bound. + +Measured: of the ~28 `&dyn TuiState` render modules, only **2** are +multi-category (`ui.rs`, `ui_viewport.rs`); the other ~26 each use a single +domain. So a sub-trait split *does* narrow the declared surface for the majority +of render modules, but the headline god-interface (driven by the 2 central +renderers) stays wide via the supertrait. + +Conclusion: the split is worthwhile for readability and for narrowing leaf +render-module bounds, but it is **not** a compile-coupling win and should be done +incrementally to avoid a high-conflict big-bang across 29 files. + +## Proposed target shape + +``` +trait TuiState: + TuiTranscriptState + TuiInputState + TuiScrollState + TuiStreamStatusState + + TuiProviderState + TuiSessionServerState + TuiWorkspaceState + + TuiDiagramPaneState + TuiDiffPaneState + TuiSidePanelState + + TuiInlineState + TuiOverlayState + TuiCopySelectionState + + TuiOnboardingState + TuiMiscState +{} +``` + +`App` and `TestState` keep a single `impl` per sub-trait (mechanical move). The 2 +central renderers take `&dyn TuiState` (the supertrait). Each leaf render module +narrows to the one sub-trait it needs. + +## Method categorization (all 114) + +### TuiTranscriptState +display_messages, display_user_message_count, compacted_hidden_user_prompts, +has_display_edit_tool_messages, side_pane_images, display_messages_version, +render_streaming_markdown + +### TuiInputState +input, cursor_pos, queued_messages, interleave_message, +pending_soft_interrupts, has_stashed_input, command_suggestions, +command_suggestion_selected, suggestion_prompts, queue_mode, +next_prompt_new_session_armed, dictation_key_label + +### TuiScrollState +scroll_offset, auto_scroll_paused, chat_overscroll_active, +copy_selection_edge_autoscroll_active, chat_native_scrollbar, +has_pending_mouse_scroll_animation + +### TuiStreamStatusState +streaming_text, is_processing, streaming_tokens, streaming_cache_tokens, +output_tps, streaming_tool_calls, elapsed, status, active_skill, +subagent_status, batch_progress, time_since_activity, stream_message_ended, +status_notice, status_detail, rate_limit_remaining, animation_elapsed + +### TuiProviderState +provider_name, provider_model, upstream_provider, connection_type, +mcp_servers, available_skills, auth_status, update_cost, +total_session_tokens, session_compaction_count, context_info, +context_snapshot, context_limit, cache_ttl_status + +### TuiSessionServerState +is_remote_mode, is_canary, is_replay, current_session_id, +session_display_name, server_display_name, server_display_icon, +server_sessions, connected_clients, remote_startup_phase_active, +client_update_available, server_update_available, info_widget_data, +active_experimental_feature_notice + +### TuiWorkspaceState +workspace_mode_enabled, workspace_map_rows, workspace_animation_tick + +### TuiDiagramPaneState +diagram_mode, diagram_focus, diagram_index, diagram_scroll, +diagram_pane_ratio, diagram_pane_ratio_user_adjusted, diagram_pane_animating, +diagram_pane_enabled, diagram_pane_position, diagram_zoom + +### TuiDiffPaneState +diff_mode, diff_pane_scroll, diff_pane_scroll_x, diff_pane_focus, +diff_line_wrap + +### TuiSidePanelState +side_panel, side_panel_image_zoom_percent, side_panel_native_scrollbar, +pin_images, pinned_images_auto_hide_remaining_secs + +### TuiInlineState +inline_interactive_state, inline_view_state, inline_ui_state + +### TuiOverlayState +changelog_scroll, help_scroll, model_status_overlay, session_picker_overlay, +login_picker_overlay, account_picker_overlay, usage_overlay + +### TuiCopySelectionState +copy_badge_ui, copy_selection_mode, copy_selection_range, +copy_selection_status + +### TuiOnboardingState +onboarding_preview_mode, onboarding_welcome_active, onboarding_welcome_kind + +### TuiMiscState +working_dir, now_millis, has_notification, centered_mode + +## Incremental, low-conflict migration + +Do **not** split all 15 sub-traits at once across 29 files. Recommended order: + +1. Land the documented section headers in the trait definition (done; pure + comments, single file). Gives the categorization a canonical home. +2. Extract one leaf sub-trait with a single-file consumer as a proof of pattern + (e.g. `TuiCopySelectionState` or `TuiDiagramPaneState`). Verify with + `cargo check -p jcode-tui`. +3. Extract remaining leaf sub-traits one per commit, narrowing the corresponding + leaf render module's bound in the same commit. +4. Keep `ui.rs` and `ui_viewport.rs` on the `TuiState` supertrait throughout. + +Each step is behavior-preserving (data accessors only) and compiles +independently, so it can be merged between other agents' work without a +big-bang conflict. + +## Verification + +- `cargo check -p jcode-tui` after each sub-trait extraction (TMPDIR must point + at real disk, not the RAM-backed tmpfs, or ring/aws-lc-sys build scripts fail + with "Disk quota exceeded"). +- `cargo test -p jcode-tui --lib` once at the end. Note: the lib test suite has + pre-existing flaky parallel-order failures unrelated to this trait (verify any + failing test in isolation with `--test-threads=1`). From 6c6dae1e2f1d8984fc401b75cc087f12ab00153b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:22:42 -0700 Subject: [PATCH 16/41] bench: exercise Anthropic fast mode on direct API-key path Add BENCH_ANTHROPIC_API_KEY=1 to pin the direct Console API-key credential (x-api-key) instead of the subscription OAuth route, and log the resolved key prefix under JCODE_LOG_SERVICE_TIER so we can confirm which credential/tier was actually used. Both OAuth and API-key paths return service_tier=standard on this account (no fast-mode credits). --- crates/jcode-base/src/provider/anthropic.rs | 19 ++++++++++++++++--- examples/bench_anthropic_essay_tps.rs | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/jcode-base/src/provider/anthropic.rs b/crates/jcode-base/src/provider/anthropic.rs index 1d0a35b79..6f25a75fe 100644 --- a/crates/jcode-base/src/provider/anthropic.rs +++ b/crates/jcode-base/src/provider/anthropic.rs @@ -440,8 +440,19 @@ impl AnthropicCredentialMode { } pub(crate) fn load_anthropic_api_key() -> Result { - crate::provider_catalog::load_api_key_from_env_or_config("ANTHROPIC_API_KEY", "anthropic.env") - .context("No Anthropic API key found") + let key = crate::provider_catalog::load_api_key_from_env_or_config( + "ANTHROPIC_API_KEY", + "anthropic.env", + ) + .context("No Anthropic API key found")?; + if std::env::var("JCODE_LOG_SERVICE_TIER").is_ok() { + let prefix: String = key.chars().take(14).collect(); + eprintln!( + "[anthropic] resolved API key prefix={prefix}... (len={})", + key.len() + ); + } + Ok(key) } pub(crate) fn has_anthropic_api_key() -> bool { @@ -1073,7 +1084,9 @@ impl AnthropicProvider { signature: signature.clone(), }); } - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { result.push(ApiContentBlock::ToolUse { id: crate::message::sanitize_tool_id(id), name: if is_oauth { diff --git a/examples/bench_anthropic_essay_tps.rs b/examples/bench_anthropic_essay_tps.rs index fa0fbdd16..c10d476e5 100644 --- a/examples/bench_anthropic_essay_tps.rs +++ b/examples/bench_anthropic_essay_tps.rs @@ -109,6 +109,15 @@ async fn main() -> Result<()> { .nth(1) .and_then(|s| s.parse::().ok()) .unwrap_or(3000); + + // Force the direct Anthropic API-key path when requested (or when an API + // key is present and OAuth is not), so fast mode is exercised on the + // Console API rather than the subscription OAuth route. Fast mode / priority + // tier is gated by usage credits on the API account. + let force_api_key = std::env::var("BENCH_ANTHROPIC_API_KEY") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + println!( "tier,first_ms,last_text_ms,total_ms,generation_ms,chars,input_tokens,output_tokens,cache_read,cache_write,gen_output_tok_s,total_output_tok_s" ); @@ -118,6 +127,14 @@ async fn main() -> Result<()> { let fast = AnthropicProvider::new(); fast.set_model("claude-opus-4-8")?; fast.set_service_tier("priority")?; + + if force_api_key { + // false = API key (not OAuth) + standard.pin_credential_mode_for_doctor(false)?; + fast.pin_credential_mode_for_doctor(false)?; + eprintln!("[bench] forcing direct Anthropic API-key credential mode"); + } + run_one_with_retry(&standard, "standard_only", words, 4).await?; // Cool-down gap to avoid back-to-back rate limiting between the two runs. tokio::time::sleep(std::time::Duration::from_secs(20)).await; From f3d6b4016682c634dd4b3402b1b72440967426c2 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:24:22 -0700 Subject: [PATCH 17/41] Add compile-time isolation probes --- Cargo.lock | 2 +- crates/jcode-memory-types/Cargo.toml | 2 +- crates/jcode-memory-types/src/lib.rs | 8 +- docs/COMPILE_PERFORMANCE_PLAN.md | 1 + docs/COMPILE_TIME_ISOLATION_REFACTOR.md | 220 +++++++++++++++ scripts/compile_isolation_report.py | 231 ++++++++++++++++ scripts/compile_time_probe.sh | 344 ++++++++++++++++++++++++ 7 files changed, 805 insertions(+), 3 deletions(-) create mode 100644 docs/COMPILE_TIME_ISOLATION_REFACTOR.md create mode 100755 scripts/compile_isolation_report.py create mode 100755 scripts/compile_time_probe.sh diff --git a/Cargo.lock b/Cargo.lock index 486c4a7ca..19edf3c34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3727,7 +3727,7 @@ name = "jcode-memory-types" version = "0.1.0" dependencies = [ "chrono", - "jcode-core", + "rand 0.9.3", "serde", "serde_json", ] diff --git a/crates/jcode-memory-types/Cargo.toml b/crates/jcode-memory-types/Cargo.toml index e992ed5d9..e81b1a726 100644 --- a/crates/jcode-memory-types/Cargo.toml +++ b/crates/jcode-memory-types/Cargo.toml @@ -6,6 +6,6 @@ publish = false [dependencies] chrono = { version = "0.4", features = ["serde"] } -jcode-core = { path = "../jcode-core" } +rand = "0.9.3" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/jcode-memory-types/src/lib.rs b/crates/jcode-memory-types/src/lib.rs index a9891841d..341ffe5d2 100644 --- a/crates/jcode-memory-types/src/lib.rs +++ b/crates/jcode-memory-types/src/lib.rs @@ -273,12 +273,18 @@ fn default_active() -> bool { true } +fn new_memory_id() -> String { + let ts = Utc::now().timestamp_millis(); + let rand: u64 = rand::random(); + format!("mem_{ts}_{rand}") +} + impl MemoryEntry { pub fn new(category: MemoryCategory, content: impl Into) -> Self { let now = Utc::now(); let content = content.into(); Self { - id: jcode_core::id::new_id("mem"), + id: new_memory_id(), category, search_text: normalize_memory_search_text(&content, &[]), content, diff --git a/docs/COMPILE_PERFORMANCE_PLAN.md b/docs/COMPILE_PERFORMANCE_PLAN.md index 0d8b9ebdd..5198a63a3 100644 --- a/docs/COMPILE_PERFORMANCE_PLAN.md +++ b/docs/COMPILE_PERFORMANCE_PLAN.md @@ -5,6 +5,7 @@ without sacrificing full-feature builds. See also: +- [`COMPILE_TIME_ISOLATION_REFACTOR.md`](./COMPILE_TIME_ISOLATION_REFACTOR.md) - [`REFACTORING.md`](./REFACTORING.md) - [`MODULAR_ARCHITECTURE_RFC.md`](./MODULAR_ARCHITECTURE_RFC.md) diff --git a/docs/COMPILE_TIME_ISOLATION_REFACTOR.md b/docs/COMPILE_TIME_ISOLATION_REFACTOR.md new file mode 100644 index 000000000..1d29abeea --- /dev/null +++ b/docs/COMPILE_TIME_ISOLATION_REFACTOR.md @@ -0,0 +1,220 @@ +# Compile-Time Isolation Refactor + +This is the active migration plan for making full-feature debug/selfdev builds faster without removing features from the developer binary. + +## Goal + +Keep the normal debug/selfdev binary production-like, including PDF, embeddings, providers, update/selfdev tooling, and other integrations, while reducing the amount of Rust code that must be recompiled after common edits. + +The target is not just "more crates". The target is a wider dependency DAG with smaller serial front-end units and cleaner invalidation boundaries. + +## Current diagnosis + +The workspace already has many crates, but the critical path is dominated by a small number of large crates stacked linearly: + +```mermaid +graph LR + base["jcode-base\n~100k+ LOC"] --> appcore["jcode-app-core\n~100k LOC"] + appcore --> tui["jcode-tui\n~100k+ LOC"] + tui --> rootlib["jcode lib"] + rootlib --> bin["jcode bin"] + small["50+ smaller crates"] -. mostly parallel .-> base +``` + +From the last available Cargo timing report parsed with `scripts/compile_time_probe.sh --skip-build`: + +- Cargo timing wall: **16.00s** +- Known jcode serial stack span: **14.72s** +- Known jcode serial stack summed unit time: **17.36s** +- Known jcode serial stack frontend time: **11.99s** + +Slowest units from that timing report: + +| Unit | Total | Frontend | Codegen | +|---|---:|---:|---:| +| `jcode-app-core` | 4.73s | 3.82s | 0.91s | +| `jcode-base` | 4.34s | 3.63s | 0.71s | +| `jcode-tui` | 4.18s | 3.14s | 1.04s | +| `jcode` bin | 2.34s | n/a | n/a | +| `jcode` lib | 1.77s | 1.40s | 0.37s | + +This means the main bottleneck is rustc front-end serialization in a few mega-crates, not linker choice or third-party cold compile. + +## Measurement + +Use the focused timing probe for each phase: + +```bash +scripts/compile_time_probe.sh --json target/compile-time-probe.json +scripts/compile_time_probe.sh --touch crates/jcode-tui/src/tui/app/input.rs +scripts/compile_time_probe.sh --touch crates/jcode-app-core/src/server.rs +scripts/compile_time_probe.sh --touch crates/jcode-base/src/provider/mod.rs +``` + +For broader repeated measurements, continue using: + +```bash +scripts/bench_compile.sh selfdev-jcode --runs 3 --touch --json +scripts/bench_selfdev_checkpoints.sh --skip-cold --touch --runs 1 +``` + +Track at least: + +1. Full-feature selfdev build wall time. +2. Cargo timing wall time. +3. `jcode-base -> jcode-app-core -> jcode-tui -> jcode lib -> jcode bin` stack span. +4. Sum of frontend time in the serial stack. +5. Incremental rebuild after touching representative high-churn files. +6. Static report drift from `scripts/compile_isolation_report.py`: LOC, inline tests, `async_trait`, and target-state dependency advisories. + +## Target architecture + +```mermaid +graph TD + bin["jcode binary\ntiny composition root"] --> cli["jcode-cli"] + bin --> tui["jcode-tui"] + bin --> server["jcode-server"] + bin --> providers["provider leaf crates"] + bin --> tools["tool leaf crates"] + + cli --> api["jcode-client-api / app-api"] + tui --> api + server --> api + + api --> protocol["protocol + view models"] + protocol --> types["small stable type crates"] + + server --> agent["jcode-agent"] + server --> registry["jcode-tool-registry"] + server --> auth["jcode-auth-core"] + server --> session["jcode-session-core"] + server --> memory["jcode-memory-core"] + + providers --> provider_core["jcode-provider-core"] + tools --> tool_core["jcode-tool-core"] +``` + +Rules: + +- TUI and CLI depend on client API, protocol, view models, and small type crates, not full server/provider/tool implementations. +- Provider implementations are leaf crates. AWS/Bedrock dependencies live only in the Bedrock provider crate. +- Tool implementations are leaf crates. Heavy tools like PDF/browser/Gmail/search are isolated behind tool-core interfaces. +- Shared bottom crates are small and stable. Avoid putting high-churn behavior in protocol/type crates. +- Avoid broad `pub use whole_crate::*` compatibility ladders in final architecture. + +## Migration sequence + +### Phase 0: measurement and guardrails + +Status: started. + +Deliverables: + +- `scripts/compile_time_probe.sh` +- `scripts/compile_isolation_report.py` +- this document +- dependency boundary checks/advisory reports + +Success criteria: + +- Every structural phase has before/after timing. +- The timing report makes the serial stack visible. + +### Phase 1: widen the god-crate critical path + +Split the three long-pole crates into sibling domain crates. Priority is widening the graph, not extracting more tiny type crates. + +Likely first splits: + +- From `jcode-base`: + - `jcode-auth-core` + - `jcode-session-core` + - `jcode-memory-core` + - provider implementation crates, especially Bedrock/AWS as a leaf +- From `jcode-app-core`: + - `jcode-server` + - `jcode-agent` + - `jcode-tool-registry` + - service crates for background/swarm/update/selfdev as needed +- From `jcode-tui`: + - `jcode-client-api` / view-model boundary first + - then move reusable client-side state logic out of the terminal rendering crate only when it creates a real parallel unit + +Success criteria: + +- Touching common TUI code no longer recompiles app-core/provider/server implementation crates. +- Touching a provider implementation no longer recompiles TUI or broad base code. +- Cargo timing shows multiple medium-sized Jcode crates running in parallel instead of one 4-deep mega-crate ladder. + +### Phase 2: kill glob re-export ladders + +Current compatibility layering preserves the old monolith shape: + +```rust +pub use jcode_base::*; +pub use jcode_app_core::*; +pub use jcode_tui::*; +``` + +Migration approach: + +1. Keep compatibility re-exports temporarily while moving code. +2. Convert high-churn modules to explicit imports from leaf crates. +3. Remove glob re-exports once downstream imports are explicit. + +Success criteria: + +- New code does not rely on whole-layer prelude-style re-exports. +- Dependency direction is visible in imports and Cargo manifests. + +### Phase 3: move inline tests out of hot crates + +Problem: + +- Inline `#[cfg(test)]` modules make `cargo test` compile large production crates plus large test bodies as one rustc unit. + +Target: + +- Integration tests or dedicated `*-test-support` crates for broad behavior tests. +- Keep tiny unit tests inline only when they are genuinely local and cheap. + +Success criteria: + +- Targeted tests no longer require monolithic test cfg builds for unrelated domains. + +### Phase 4: reduce front-end macro tax + +Targets: + +- Replace `async_trait` with native `async fn` in traits where the trait is not used as `dyn`. +- Keep `async_trait` only at object-safe plugin/interface boundaries where boxed futures are intentional. +- Avoid adding derive-heavy types to broad shared crates unless the type is stable and necessary. + +Success criteria: + +- Fewer proc-macro expansions in the hot crates. +- No object-safety regressions. + +## Anti-goals + +- Do not make fast debug builds incomplete by default. +- Do not split code into tiny crates unless the split creates a real invalidation or parallelism boundary. +- Do not move high-churn behavior into low-level type/protocol crates. +- Do not do a single giant rewrite. Each phase should build and be measurable. + +## Validation checklist per phase + +Before committing a phase: + +```bash +scripts/compile_time_probe.sh --skip-build +scripts/compile_isolation_report.py +scripts/check_dependency_boundaries.py +cargo check --profile selfdev -p jcode --bin jcode +``` + +For code-moving phases, also run the relevant targeted tests for the moved domain, plus one full selfdev build through the coordinated selfdev path when practical: + +```bash +selfdev build target=tui +``` diff --git a/scripts/compile_isolation_report.py b/scripts/compile_isolation_report.py new file mode 100755 index 000000000..9c8f45274 --- /dev/null +++ b/scripts/compile_isolation_report.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Report compile-time isolation risks in the Jcode crate graph. + +This is advisory by default. Use --strict-target-state only when a migration phase +has removed the listed temporary violations and we want to prevent regressions. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +WATCHED_CRATES = ["jcode-base", "jcode-app-core", "jcode-tui", "jcode"] + + +@dataclass +class CrateStats: + name: str + manifest_path: str + src_path: str | None + rust_files: int + loc: int + cfg_test_count: int + test_attr_count: int + async_trait_count: int + derive_count: int + glob_reexports: list[str] + direct_workspace_deps: list[str] + direct_external_deps: list[str] + + +def run_metadata() -> dict[str, Any]: + result = subprocess.run( + ["cargo", "metadata", "--no-deps", "--format-version", "1"], + cwd=ROOT, + check=True, + text=True, + stdout=subprocess.PIPE, + ) + return json.loads(result.stdout) + + +def lib_or_root_src(package: dict[str, Any]) -> Path | None: + manifest = Path(package["manifest_path"]) + for target in package.get("targets", []): + if "lib" in target.get("kind", []): + return Path(target["src_path"]) + if package["name"] == "jcode": + return ROOT / "src" + src = manifest.parent / "src" + return src if src.exists() else None + + +def iter_rust_files(src_path: Path | None) -> list[Path]: + if src_path is None: + return [] + if src_path.is_file(): + root = src_path.parent + else: + root = src_path + if not root.exists(): + return [] + return sorted(path for path in root.rglob("*.rs") if path.is_file()) + + +def count_file(path: Path) -> tuple[int, str]: + text = path.read_text(errors="replace") + return text.count("\n") + (0 if text.endswith("\n") or not text else 1), text + + +def collect_stats(package: dict[str, Any], workspace_names: set[str]) -> CrateStats: + src_path = lib_or_root_src(package) + rust_files = iter_rust_files(src_path) + loc = 0 + cfg_test_count = 0 + test_attr_count = 0 + async_trait_count = 0 + derive_count = 0 + glob_reexports: list[str] = [] + + for path in rust_files: + file_loc, text = count_file(path) + loc += file_loc + cfg_test_count += len(re.findall(r"#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]", text)) + test_attr_count += len(re.findall(r"#\s*\[\s*(?:tokio::)?test(?:\s*\([^\]]*\))?\s*\]", text)) + async_trait_count += len(re.findall(r"#\s*\[\s*(?:async_trait::)?async_trait\s*\]", text)) + derive_count += len(re.findall(r"#\s*\[\s*derive\s*\(", text)) + for line_number, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("//"): + continue + if re.fullmatch(r"pub\s+use\s+[^;\n]+::\s*\*\s*;", stripped): + rel = path.relative_to(ROOT) + glob_reexports.append(f"{rel}:{line_number}: {stripped}") + + workspace_deps: list[str] = [] + external_deps: list[str] = [] + for dep in package.get("dependencies", []): + dep_name = dep["name"] + if dep_name in workspace_names: + workspace_deps.append(dep_name) + else: + external_deps.append(dep_name) + + return CrateStats( + name=package["name"], + manifest_path=str(Path(package["manifest_path"]).relative_to(ROOT)), + src_path=str(src_path.relative_to(ROOT)) if src_path and src_path.exists() else None, + rust_files=len(rust_files), + loc=loc, + cfg_test_count=cfg_test_count, + test_attr_count=test_attr_count, + async_trait_count=async_trait_count, + derive_count=derive_count, + glob_reexports=glob_reexports, + direct_workspace_deps=sorted(workspace_deps), + direct_external_deps=sorted(external_deps), + ) + + +def target_state_violations(stats_by_name: dict[str, CrateStats]) -> list[str]: + violations: list[str] = [] + + tui = stats_by_name.get("jcode-tui") + if tui and "jcode-app-core" in tui.direct_workspace_deps: + violations.append("target-state: jcode-tui still directly depends on jcode-app-core") + + app_core = stats_by_name.get("jcode-app-core") + if app_core and "jcode-base" in app_core.direct_workspace_deps: + violations.append("target-state: jcode-app-core still directly depends on jcode-base") + + base = stats_by_name.get("jcode-base") + if base: + for dep in base.direct_workspace_deps: + if dep in { + "jcode-azure-auth", + "jcode-provider-gemini", + "jcode-provider-openai", + "jcode-provider-openrouter", + "jcode-notify-email", + "jcode-build-support", + }: + violations.append(f"target-state: jcode-base still depends on leaf/runtime crate {dep}") + for dep in base.direct_external_deps: + if dep.startswith("aws-") or dep in {"aws-types"}: + violations.append(f"target-state: jcode-base still depends directly on AWS crate {dep}") + + for crate in stats_by_name.values(): + for glob in crate.glob_reexports: + if crate.name in {"jcode", "jcode-app-core", "jcode-tui"}: + violations.append(f"target-state: broad glob re-export remains in {glob}") + + return violations + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="print machine-readable JSON") + parser.add_argument( + "--strict-target-state", + action="store_true", + help="exit non-zero for target-state violations (advisory by default)", + ) + parser.add_argument( + "--top", + type=int, + default=12, + help="number of largest workspace crates to print in text mode", + ) + args = parser.parse_args() + + metadata = run_metadata() + package_by_id = {package["id"]: package for package in metadata["packages"]} + workspace_packages = [package_by_id[package_id] for package_id in metadata["workspace_members"]] + workspace_names = {package["name"] for package in workspace_packages} + + stats = [collect_stats(package, workspace_names) for package in workspace_packages] + stats_by_name = {crate.name: crate for crate in stats} + violations = target_state_violations(stats_by_name) + largest = sorted(stats, key=lambda crate: crate.loc, reverse=True) + + payload = { + "watched_crates": {name: asdict(stats_by_name[name]) for name in WATCHED_CRATES if name in stats_by_name}, + "largest_crates": [asdict(crate) for crate in largest[: args.top]], + "target_state_violations": violations, + } + + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print("compile isolation static report") + print("largest workspace crates by Rust LOC:") + for crate in largest[: args.top]: + print( + f" - {crate.name}: {crate.loc} LOC, {crate.rust_files} files, " + f"#[test] {crate.test_attr_count}, cfg(test) {crate.cfg_test_count}, " + f"async_trait {crate.async_trait_count}, derive {crate.derive_count}" + ) + print("watched crates:") + for name in WATCHED_CRATES: + crate = stats_by_name.get(name) + if not crate: + continue + print(f" - {name}: {crate.loc} LOC, workspace deps={len(crate.direct_workspace_deps)}, external deps={len(crate.direct_external_deps)}") + if crate.glob_reexports: + print(" glob re-exports:") + for glob in crate.glob_reexports[:8]: + print(f" {glob}") + if len(crate.glob_reexports) > 8: + print(f" ... {len(crate.glob_reexports) - 8} more") + if violations: + print("target-state violations/advisories:") + for violation in violations: + print(f" - {violation}") + else: + print("target-state violations/advisories: none") + + if args.strict_target_state and violations: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compile_time_probe.sh b/scripts/compile_time_probe.sh new file mode 100755 index 000000000..4ecdf9539 --- /dev/null +++ b/scripts/compile_time_probe.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$repo_root" + +usage() { + cat <<'USAGE' +Usage: + scripts/compile_time_probe.sh [options] + +Runs a full-feature selfdev jcode build with Cargo timings enabled and summarizes +critical-path-ish rustc units from target/cargo-timings/cargo-timing.html. + +Options: + --skip-build Parse the latest timing HTML without running cargo + --timing-html Parse a specific cargo timing HTML file + --touch Touch a file before building to simulate an edit + --profile Cargo profile to build (default: selfdev) + --package Cargo package to build (default: jcode) + --bin Cargo binary to build (default: jcode) + --feature-profile JCODE_DEV_FEATURE_PROFILE for dev_cargo.sh (default: default) + --json Write the parsed summary JSON to this path + --top Number of slowest units to print (default: 12) + -h, --help Show this help + +Examples: + scripts/compile_time_probe.sh --skip-build + scripts/compile_time_probe.sh --touch crates/jcode-tui/src/tui/app/input.rs + scripts/compile_time_probe.sh --json target/compile-time-probe.json + +Notes: + - This intentionally defaults to the full/default feature set. It is for + compile-time isolation work that keeps debug/selfdev behavior production-like. + - The "jcode serial stack" summary is not a formal Cargo critical path. It is a + focused view of the known long-pole crates: jcode-base, jcode-app-core, + jcode-tui, root jcode lib, and jcode bin. +USAGE +} + +skip_build=0 +timing_html="" +touch_path="" +profile="selfdev" +package="jcode" +bin="jcode" +feature_profile="default" +json_path="" +top_n=12 + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-build) + skip_build=1 + ;; + --timing-html) + [[ $# -ge 2 ]] || { echo 'error: --timing-html requires a path' >&2; exit 1; } + timing_html="$2" + shift + ;; + --touch) + [[ $# -ge 2 ]] || { echo 'error: --touch requires a path' >&2; exit 1; } + touch_path="$2" + shift + ;; + --profile) + [[ $# -ge 2 ]] || { echo 'error: --profile requires a value' >&2; exit 1; } + profile="$2" + shift + ;; + --package|-p) + [[ $# -ge 2 ]] || { echo 'error: --package requires a value' >&2; exit 1; } + package="$2" + shift + ;; + --bin) + [[ $# -ge 2 ]] || { echo 'error: --bin requires a value' >&2; exit 1; } + bin="$2" + shift + ;; + --feature-profile) + [[ $# -ge 2 ]] || { echo 'error: --feature-profile requires a value' >&2; exit 1; } + feature_profile="$2" + shift + ;; + --json) + [[ $# -ge 2 ]] || { echo 'error: --json requires a path' >&2; exit 1; } + json_path="$2" + shift + ;; + --top) + [[ $# -ge 2 ]] || { echo 'error: --top requires a positive integer' >&2; exit 1; } + top_n="$2" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +if ! [[ "$top_n" =~ ^[1-9][0-9]*$ ]]; then + printf 'error: --top must be a positive integer (got %s)\n' "$top_n" >&2 + exit 1 +fi + +if [[ -n "$touch_path" && ! -e "$touch_path" ]]; then + printf 'error: touch path does not exist: %s\n' "$touch_path" >&2 + exit 1 +fi + +if [[ $skip_build -eq 0 ]]; then + if [[ -n "$touch_path" ]]; then + printf 'compile_time_probe: touching %s\n' "$touch_path" >&2 + touch "$touch_path" + fi + + printf 'compile_time_probe: building %s/%s profile=%s feature_profile=%s with --timings\n' \ + "$package" "$bin" "$profile" "$feature_profile" >&2 + + start_ns=$(python3 - <<'PY' +import time +print(time.perf_counter_ns()) +PY +) + + JCODE_DEV_FEATURE_PROFILE="$feature_profile" \ + scripts/dev_cargo.sh build --profile "$profile" -p "$package" --bin "$bin" --timings + + end_ns=$(python3 - <<'PY' +import time +print(time.perf_counter_ns()) +PY +) + elapsed_seconds=$(python3 - "$start_ns" "$end_ns" <<'PY' +import sys +start = int(sys.argv[1]) +end = int(sys.argv[2]) +print(f"{(end - start) / 1_000_000_000:.3f}") +PY +) +else + elapsed_seconds="" +fi + +if [[ -z "$timing_html" ]]; then + timing_html=$(find target/cargo-timings -maxdepth 1 -type f -name 'cargo-timing*.html' -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- || true) +fi + +if [[ -z "$timing_html" || ! -f "$timing_html" ]]; then + printf 'error: no cargo timing HTML found; run without --skip-build or pass --timing-html\n' >&2 + exit 1 +fi + +python3 - "$timing_html" "$elapsed_seconds" "$json_path" "$top_n" "$profile" "$package" "$bin" "$feature_profile" <<'PY' +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +html_path = Path(sys.argv[1]) +elapsed_arg = sys.argv[2] +json_path = Path(sys.argv[3]) if sys.argv[3] else None +top_n = int(sys.argv[4]) +profile = sys.argv[5] +package = sys.argv[6] +bin_name = sys.argv[7] +feature_profile = sys.argv[8] + +text = html_path.read_text(errors="replace") + +def extract_duration() -> float | None: + match = re.search(r"(?:const\s+)?DURATION\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*;", text) + return float(match.group(1)) if match else None + + +def extract_unit_data() -> list[dict[str, Any]]: + marker = "const UNIT_DATA = " + start = text.find(marker) + if start < 0: + raise SystemExit(f"error: {html_path} does not contain Cargo UNIT_DATA") + idx = start + len(marker) + while idx < len(text) and text[idx].isspace(): + idx += 1 + if idx >= len(text) or text[idx] != "[": + raise SystemExit("error: Cargo UNIT_DATA did not start with '['") + + depth = 0 + in_string = False + escape = False + end = idx + while end < len(text): + ch = text[end] + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + end += 1 + break + end += 1 + return json.loads(text[idx:end]) + + +def section_duration(unit: dict[str, Any], section_name: str) -> float | None: + sections = unit.get("sections") + if not sections: + return None + for name, payload in sections: + if name == section_name: + return float(payload["end"]) - float(payload["start"]) + return None + + +def fmt_seconds(value: float | None) -> str: + if value is None: + return "n/a" + return f"{value:.2f}s" + +units = extract_unit_data() +for unit in units: + unit["end"] = float(unit.get("start", 0.0)) + float(unit.get("duration", 0.0)) + unit["frontend_duration"] = section_duration(unit, "frontend") + unit["codegen_duration"] = section_duration(unit, "codegen") + +# Slowest rustc-ish units by duration. Keep build-script run units visible but low-noise. +top_units = sorted(units, key=lambda unit: float(unit.get("duration", 0.0)), reverse=True)[:top_n] + +def is_jcode_stack_unit(unit: dict[str, Any]) -> bool: + name = unit.get("name") + target = unit.get("target") or "" + if name in {"jcode-base", "jcode-app-core", "jcode-tui"} and "build script" not in target: + return True + if name == "jcode" and (target == "" or f'bin "{bin_name}"' in target): + return True + return False + +jcode_stack = sorted([unit for unit in units if is_jcode_stack_unit(unit)], key=lambda unit: float(unit.get("start", 0.0))) +stack_span = None +if jcode_stack: + stack_span = max(float(unit["end"]) for unit in jcode_stack) - min(float(unit.get("start", 0.0)) for unit in jcode_stack) +stack_sum = sum(float(unit.get("duration", 0.0)) for unit in jcode_stack) +stack_frontend_sum = sum(float(unit.get("frontend_duration") or 0.0) for unit in jcode_stack) +stack_codegen_sum = sum(float(unit.get("codegen_duration") or 0.0) for unit in jcode_stack) + +summary = { + "timing_html": str(html_path), + "profile": profile, + "package": package, + "bin": bin_name, + "feature_profile": feature_profile, + "wall_seconds_from_cargo_timing": extract_duration(), + "wall_seconds_measured_by_probe": float(elapsed_arg) if elapsed_arg else None, + "unit_count": len(units), + "top_units": [ + { + "name": unit.get("name"), + "version": unit.get("version"), + "target": unit.get("target") or "", + "features": unit.get("features") or [], + "start_seconds": round(float(unit.get("start", 0.0)), 3), + "duration_seconds": round(float(unit.get("duration", 0.0)), 3), + "frontend_seconds": round(unit["frontend_duration"], 3) if unit.get("frontend_duration") is not None else None, + "codegen_seconds": round(unit["codegen_duration"], 3) if unit.get("codegen_duration") is not None else None, + } + for unit in top_units + ], + "jcode_serial_stack": { + "span_seconds": round(stack_span, 3) if stack_span is not None else None, + "sum_unit_seconds": round(stack_sum, 3), + "sum_frontend_seconds": round(stack_frontend_sum, 3), + "sum_codegen_seconds": round(stack_codegen_sum, 3), + "units": [ + { + "name": unit.get("name"), + "target": unit.get("target") or "", + "start_seconds": round(float(unit.get("start", 0.0)), 3), + "end_seconds": round(float(unit.get("end", 0.0)), 3), + "duration_seconds": round(float(unit.get("duration", 0.0)), 3), + "frontend_seconds": round(unit["frontend_duration"], 3) if unit.get("frontend_duration") is not None else None, + "codegen_seconds": round(unit["codegen_duration"], 3) if unit.get("codegen_duration") is not None else None, + "features": unit.get("features") or [], + } + for unit in jcode_stack + ], + }, +} + +if json_path: + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + +print("compile_time_probe summary") +print(f" timing html: {html_path}") +print(f" cargo timing wall: {fmt_seconds(summary['wall_seconds_from_cargo_timing'])}") +if summary["wall_seconds_measured_by_probe"] is not None: + print(f" measured wall: {fmt_seconds(summary['wall_seconds_measured_by_probe'])}") +print(f" units: {len(units)}") +print(" jcode serial stack:") +print(f" span: {fmt_seconds(stack_span)}") +print(f" sum: {stack_sum:.2f}s (frontend {stack_frontend_sum:.2f}s, codegen {stack_codegen_sum:.2f}s)") +for unit in jcode_stack: + target = unit.get("target") or "lib" + frontend = fmt_seconds(unit.get("frontend_duration")) + codegen = fmt_seconds(unit.get("codegen_duration")) + print( + f" - {unit.get('name')} {target}: " + f"start {float(unit.get('start', 0.0)):.2f}s, " + f"dur {float(unit.get('duration', 0.0)):.2f}s, " + f"frontend {frontend}, codegen {codegen}" + ) +print(f" top {top_n} units:") +for unit in top_units: + target = unit.get("target") or "lib" + frontend = fmt_seconds(unit.get("frontend_duration")) + codegen = fmt_seconds(unit.get("codegen_duration")) + print( + f" - {unit.get('name')} {target}: " + f"{float(unit.get('duration', 0.0)):.2f}s " + f"(frontend {frontend}, codegen {codegen})" + ) +if json_path: + print(f" wrote json: {json_path}") +PY From 05c71a38c6179971e223084f23c0d9bfff6a7cc5 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:30:00 -0700 Subject: [PATCH 18/41] Move build support out of base crate --- Cargo.lock | 1 - .../src/build.rs | 0 crates/jcode-app-core/src/lib.rs | 1 + crates/jcode-base/Cargo.toml | 1 - crates/jcode-base/src/lib.rs | 1 - crates/jcode-base/src/telemetry.rs | 2 +- .../jcode-base/src/telemetry/state_support.rs | 52 +++++++++++++++++-- 7 files changed, 51 insertions(+), 7 deletions(-) rename crates/{jcode-base => jcode-app-core}/src/build.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 19edf3c34..b9a4a0f50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3546,7 +3546,6 @@ dependencies = [ "jcode-background-types", "jcode-batch-types", "jcode-build-meta", - "jcode-build-support", "jcode-compaction-core", "jcode-config-types", "jcode-core", diff --git a/crates/jcode-base/src/build.rs b/crates/jcode-app-core/src/build.rs similarity index 100% rename from crates/jcode-base/src/build.rs rename to crates/jcode-app-core/src/build.rs diff --git a/crates/jcode-app-core/src/lib.rs b/crates/jcode-app-core/src/lib.rs index 49e7311d8..ddb625899 100644 --- a/crates/jcode-app-core/src/lib.rs +++ b/crates/jcode-app-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod agent; pub mod ambient; pub mod ambient_runner; pub mod ambient_scheduler; +pub mod build; pub mod catchup; pub mod channel; pub mod external_auth; diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index 785f294e7..1239f5707 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -108,7 +108,6 @@ unicode-width = "0.2" # Unicode character display width # (tool/read.rs), not here. jcode-background-types = { path = "../jcode-background-types" } jcode-batch-types = { path = "../jcode-batch-types" } -jcode-build-support = { path = "../jcode-build-support" } jcode-build-meta = { path = "../jcode-build-meta" } jcode-compaction-core = { path = "../jcode-compaction-core" } jcode-config-types = { path = "../jcode-config-types" } diff --git a/crates/jcode-base/src/lib.rs b/crates/jcode-base/src/lib.rs index e3bec7725..f4b3040e8 100644 --- a/crates/jcode-base/src/lib.rs +++ b/crates/jcode-base/src/lib.rs @@ -20,7 +20,6 @@ pub mod auth; pub mod background; pub mod browser; -pub mod build; pub mod bus; pub mod cache_tracker; pub mod client_input; diff --git a/crates/jcode-base/src/telemetry.rs b/crates/jcode-base/src/telemetry.rs index c39714de0..07242da4c 100644 --- a/crates/jcode-base/src/telemetry.rs +++ b/crates/jcode-base/src/telemetry.rs @@ -502,7 +502,7 @@ fn detect_project_profile() -> ProjectProfile { let Some(root) = cwd.as_deref() else { return profile; }; - profile.repo_present = root.join(".git").exists() || crate::build::is_jcode_repo(root); + profile.repo_present = root.join(".git").exists() || is_jcode_repo_dir(root); let mut scanned_files = 0usize; for entry in walkdir::WalkDir::new(root) .max_depth(3) diff --git a/crates/jcode-base/src/telemetry/state_support.rs b/crates/jcode-base/src/telemetry/state_support.rs index dd725f75d..ad63b2888 100644 --- a/crates/jcode-base/src/telemetry/state_support.rs +++ b/crates/jcode-base/src/telemetry/state_support.rs @@ -1,7 +1,7 @@ use super::{SESSION_STATE, sanitize_telemetry_label}; use crate::storage; use chrono::{DateTime, Datelike, Timelike, Utc}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; pub(super) fn telemetry_id_path() -> Option { @@ -263,6 +263,52 @@ pub(super) fn new_event_id() -> String { uuid::Uuid::new_v4().to_string() } +pub(super) fn is_jcode_repo_dir(dir: &Path) -> bool { + let cargo_toml = dir.join("Cargo.toml"); + if !cargo_toml.exists() || !dir.join(".git").exists() { + return false; + } + + std::fs::read_to_string(cargo_toml) + .map(|content| content.contains("name = \"jcode\"")) + .unwrap_or(false) +} + +fn find_jcode_repo_in_ancestors(start: &Path) -> Option { + start + .ancestors() + .find(|dir| is_jcode_repo_dir(dir)) + .map(Path::to_path_buf) +} + +fn telemetry_jcode_repo_dir() -> Option { + if let Ok(path) = std::env::var("JCODE_REPO_DIR") { + let path = PathBuf::from(path); + if is_jcode_repo_dir(&path) { + return Some(path); + } + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + if let Some(repo) = find_jcode_repo_in_ancestors(&manifest_dir) { + return Some(repo); + } + + if let Ok(exe) = std::env::current_exe() + && let Some(repo) = exe + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .filter(|dir| is_jcode_repo_dir(dir)) + { + return Some(repo.to_path_buf()); + } + + std::env::current_dir() + .ok() + .and_then(|cwd| find_jcode_repo_in_ancestors(&cwd)) +} + pub(super) fn build_channel() -> String { if std::env::var(jcode_selfdev_types::CLIENT_SELFDEV_ENV).is_ok() { return "selfdev".to_string(); @@ -276,14 +322,14 @@ pub(super) fn build_channel() -> String { return "local_build".to_string(); } } - if crate::build::get_repo_dir().is_some() { + if telemetry_jcode_repo_dir().is_some() { return "git_checkout".to_string(); } "release".to_string() } pub(super) fn is_git_checkout() -> bool { - crate::build::get_repo_dir().is_some() + telemetry_jcode_repo_dir().is_some() } pub(super) fn is_ci() -> bool { From 29044d5aeae80dd8ef6826f9e78fd9182be2e7e9 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:31:53 -0700 Subject: [PATCH 19/41] Remove unused notify email dep from base --- crates/jcode-base/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index 1239f5707..cd82bb2f5 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -74,7 +74,6 @@ jcode-auth-types = { path = "../jcode-auth-types" } jcode-azure-auth = { path = "../jcode-azure-auth" } jcode-agent-runtime = { path = "../jcode-agent-runtime" } jcode-ambient-types = { path = "../jcode-ambient-types" } -jcode-notify-email = { path = "../jcode-notify-email" } jcode-provider-metadata = { path = "../jcode-provider-metadata" } jcode-provider-core = { path = "../jcode-provider-core" } jcode-provider-openai = { path = "../jcode-provider-openai" } From 6661686b566c3c630af5e282d43a4db097b6c2a4 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:38:19 -0700 Subject: [PATCH 20/41] Prune unused base crate dependencies --- Cargo.lock | 9 --------- crates/jcode-base/Cargo.toml | 14 +------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9a4a0f50..77e850fce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3528,7 +3528,6 @@ dependencies = [ "base64 0.22.1", "bytes", "chrono", - "clap", "crossterm", "dirs", "flate2", @@ -3539,7 +3538,6 @@ dependencies = [ "ignore", "image", "jcode-agent-runtime", - "jcode-ambient-types", "jcode-app-core", "jcode-auth-types", "jcode-azure-auth", @@ -3555,8 +3553,6 @@ dependencies = [ "jcode-logging", "jcode-memory-types", "jcode-message-types", - "jcode-notify-email", - "jcode-overnight-core", "jcode-plan", "jcode-protocol", "jcode-provider-core", @@ -3569,13 +3565,11 @@ dependencies = [ "jcode-session-types", "jcode-side-panel-types", "jcode-storage", - "jcode-swarm-core", "jcode-task-types", "jcode-terminal-image", "jcode-terminal-launch", "jcode-tool-core", "jcode-tool-types", - "jcode-update-core", "jcode-usage-types", "libc", "open", @@ -3590,9 +3584,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "similar", - "tar", "tempfile", - "thiserror 1.0.69", "tikv-jemalloc-ctl", "tikv-jemalloc-sys", "tikv-jemallocator", @@ -3600,7 +3592,6 @@ dependencies = [ "tokio-stream", "tokio-tungstenite", "toml", - "unicode-width 0.2.0", "url", "urlencoding", "uuid", diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index cd82bb2f5..f2cfdd6de 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -37,9 +37,6 @@ serde_json = { version = "1", features = ["raw_value"] } serde_yaml = "0.9" toml = "0.8" -# CLI -clap = { version = "4", features = ["derive"] } - # File operations glob = "0.3" ignore = "0.4" # gitignore-aware file walking @@ -49,7 +46,6 @@ similar = "2" # diffing for edits # Utilities dirs = "5" # home directory anyhow = "1" -thiserror = "1" libc = "0.2" # Unix system calls (flock) chrono = { version = "0.4", features = ["serde"] } regex = "1" @@ -73,7 +69,6 @@ open = "5" # Open URLs in browser jcode-auth-types = { path = "../jcode-auth-types" } jcode-azure-auth = { path = "../jcode-azure-auth" } jcode-agent-runtime = { path = "../jcode-agent-runtime" } -jcode-ambient-types = { path = "../jcode-ambient-types" } jcode-provider-metadata = { path = "../jcode-provider-metadata" } jcode-provider-core = { path = "../jcode-provider-core" } jcode-provider-openai = { path = "../jcode-provider-openai" } @@ -85,7 +80,6 @@ jcode-provider-gemini = { path = "../jcode-provider-gemini" } # `jcode-render-core` respectively, so the layering inversion (foundation # depending on presentation) is gone. jcode-render-core = { path = "../jcode-render-core" } -jcode-update-core = { path = "../jcode-update-core" } jcode-terminal-launch = { path = "../jcode-terminal-launch" } jcode-terminal-image = { path = "../jcode-terminal-image" } jcode-usage-types = { path = "../jcode-usage-types" } @@ -100,9 +94,6 @@ bytes = "1" crossterm = { version = "0.29", features = ["event-stream"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Only PNG/JPEG (skip avif/rav1e, exr, gif, tiff, etc) -# Markdown & syntax highlighting -unicode-width = "0.2" # Unicode character display width - # NOTE: PDF text extraction (jcode-pdf) lives in the upper jcode-app-core crate # (tool/read.rs), not here. jcode-background-types = { path = "../jcode-background-types" } @@ -113,9 +104,7 @@ jcode-config-types = { path = "../jcode-config-types" } jcode-core = { path = "../jcode-core" } jcode-memory-types = { path = "../jcode-memory-types" } jcode-message-types = { path = "../jcode-message-types" } -jcode-overnight-core = { path = "../jcode-overnight-core" } jcode-plan = { path = "../jcode-plan" } -jcode-swarm-core = { path = "../jcode-swarm-core" } jcode-protocol = { path = "../jcode-protocol" } jcode-selfdev-types = { path = "../jcode-selfdev-types" } jcode-session-types = { path = "../jcode-session-types" } @@ -125,9 +114,8 @@ jcode-tool-core = { path = "../jcode-tool-core" } jcode-tool-types = { path = "../jcode-tool-types" } jcode-side-panel-types = { path = "../jcode-side-panel-types" } -# Archive extraction (for auto-update) +# Gzip decoding (used by provider import/helpers) flate2 = "1" -tar = "0.4" tempfile = "3" agentgrep = { git = "https://github.com/1jehuang/agentgrep.git", tag = "v0.1.2" } qrcode = { version = "0.14.1", default-features = false } From a3da59c9d0f38b4e9f614155e19da5d2f16cd629 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:44:10 -0700 Subject: [PATCH 21/41] Clarify compile isolation dependency reporting --- Cargo.lock | 1 - crates/jcode-base/Cargo.toml | 1 - scripts/compile_isolation_report.py | 50 ++++++++++++++++++++--------- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77e850fce..af50937ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3578,7 +3578,6 @@ dependencies = [ "rand 0.9.3", "regex", "reqwest 0.12.28", - "rustls 0.23.37", "serde", "serde_json", "serde_yaml", diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index f2cfdd6de..fe36db408 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -28,7 +28,6 @@ async-trait = "0.1" # HTTP client reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "blocking", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] } -rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"] } tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] } # Serialization diff --git a/scripts/compile_isolation_report.py b/scripts/compile_isolation_report.py index 9c8f45274..1136cda5d 100755 --- a/scripts/compile_isolation_report.py +++ b/scripts/compile_isolation_report.py @@ -32,8 +32,12 @@ class CrateStats: async_trait_count: int derive_count: int glob_reexports: list[str] - direct_workspace_deps: list[str] - direct_external_deps: list[str] + normal_workspace_deps: list[str] + normal_external_deps: list[str] + dev_workspace_deps: list[str] + dev_external_deps: list[str] + build_workspace_deps: list[str] + build_external_deps: list[str] def run_metadata() -> dict[str, Any]: @@ -100,14 +104,15 @@ def collect_stats(package: dict[str, Any], workspace_names: set[str]) -> CrateSt rel = path.relative_to(ROOT) glob_reexports.append(f"{rel}:{line_number}: {stripped}") - workspace_deps: list[str] = [] - external_deps: list[str] = [] + workspace_deps_by_kind: dict[str, list[str]] = {"normal": [], "dev": [], "build": []} + external_deps_by_kind: dict[str, list[str]] = {"normal": [], "dev": [], "build": []} for dep in package.get("dependencies", []): dep_name = dep["name"] - if dep_name in workspace_names: - workspace_deps.append(dep_name) - else: - external_deps.append(dep_name) + dep_kind = dep.get("kind") or "normal" + if dep_kind not in workspace_deps_by_kind: + dep_kind = "normal" + buckets = workspace_deps_by_kind if dep_name in workspace_names else external_deps_by_kind + buckets[dep_kind].append(dep_name) return CrateStats( name=package["name"], @@ -120,8 +125,12 @@ def collect_stats(package: dict[str, Any], workspace_names: set[str]) -> CrateSt async_trait_count=async_trait_count, derive_count=derive_count, glob_reexports=glob_reexports, - direct_workspace_deps=sorted(workspace_deps), - direct_external_deps=sorted(external_deps), + normal_workspace_deps=sorted(workspace_deps_by_kind["normal"]), + normal_external_deps=sorted(external_deps_by_kind["normal"]), + dev_workspace_deps=sorted(workspace_deps_by_kind["dev"]), + dev_external_deps=sorted(external_deps_by_kind["dev"]), + build_workspace_deps=sorted(workspace_deps_by_kind["build"]), + build_external_deps=sorted(external_deps_by_kind["build"]), ) @@ -129,16 +138,16 @@ def target_state_violations(stats_by_name: dict[str, CrateStats]) -> list[str]: violations: list[str] = [] tui = stats_by_name.get("jcode-tui") - if tui and "jcode-app-core" in tui.direct_workspace_deps: + if tui and "jcode-app-core" in tui.normal_workspace_deps: violations.append("target-state: jcode-tui still directly depends on jcode-app-core") app_core = stats_by_name.get("jcode-app-core") - if app_core and "jcode-base" in app_core.direct_workspace_deps: + if app_core and "jcode-base" in app_core.normal_workspace_deps: violations.append("target-state: jcode-app-core still directly depends on jcode-base") base = stats_by_name.get("jcode-base") if base: - for dep in base.direct_workspace_deps: + for dep in base.normal_workspace_deps: if dep in { "jcode-azure-auth", "jcode-provider-gemini", @@ -148,7 +157,7 @@ def target_state_violations(stats_by_name: dict[str, CrateStats]) -> list[str]: "jcode-build-support", }: violations.append(f"target-state: jcode-base still depends on leaf/runtime crate {dep}") - for dep in base.direct_external_deps: + for dep in base.normal_external_deps: if dep.startswith("aws-") or dep in {"aws-types"}: violations.append(f"target-state: jcode-base still depends directly on AWS crate {dep}") @@ -208,7 +217,18 @@ def main() -> int: crate = stats_by_name.get(name) if not crate: continue - print(f" - {name}: {crate.loc} LOC, workspace deps={len(crate.direct_workspace_deps)}, external deps={len(crate.direct_external_deps)}") + print( + f" - {name}: {crate.loc} LOC, " + f"normal workspace deps={len(crate.normal_workspace_deps)}, " + f"normal external deps={len(crate.normal_external_deps)}" + ) + if crate.dev_workspace_deps or crate.dev_external_deps or crate.build_workspace_deps or crate.build_external_deps: + print( + f" non-normal deps: dev workspace={len(crate.dev_workspace_deps)}, " + f"dev external={len(crate.dev_external_deps)}, " + f"build workspace={len(crate.build_workspace_deps)}, " + f"build external={len(crate.build_external_deps)}" + ) if crate.glob_reexports: print(" glob re-exports:") for glob in crate.glob_reexports[:8]: From 7fb55f12b86f66746d57c6a77bd0385533499fcf Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:54:47 -0700 Subject: [PATCH 22/41] refactor(tui): move memory tile rendering to render crate --- Cargo.lock | 1 + crates/jcode-tui-render/Cargo.toml | 1 + crates/jcode-tui-render/src/lib.rs | 1 + crates/jcode-tui-render/src/memory_tiles.rs | 587 +++++++++++++++++++ crates/jcode-tui/src/tui/ui_memory.rs | 588 +------------------- 5 files changed, 591 insertions(+), 587 deletions(-) create mode 100644 crates/jcode-tui-render/src/memory_tiles.rs diff --git a/Cargo.lock b/Cargo.lock index af50937ce..cc40e318c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4097,6 +4097,7 @@ dependencies = [ name = "jcode-tui-render" version = "0.1.0" dependencies = [ + "chrono", "ratatui", "unicode-width 0.2.0", ] diff --git a/crates/jcode-tui-render/Cargo.toml b/crates/jcode-tui-render/Cargo.toml index b70b90c37..65e04aa53 100644 --- a/crates/jcode-tui-render/Cargo.toml +++ b/crates/jcode-tui-render/Cargo.toml @@ -5,5 +5,6 @@ edition = "2024" publish = false [dependencies] +chrono = { version = "0.4", features = ["serde"] } ratatui = "0.30" unicode-width = "0.2" diff --git a/crates/jcode-tui-render/src/lib.rs b/crates/jcode-tui-render/src/lib.rs index ef9a1d55b..35629a84c 100644 --- a/crates/jcode-tui-render/src/lib.rs +++ b/crates/jcode-tui-render/src/lib.rs @@ -1,5 +1,6 @@ pub mod chrome; pub mod layout; +pub mod memory_tiles; use ratatui::prelude::{Line, Span, Style}; diff --git a/crates/jcode-tui-render/src/memory_tiles.rs b/crates/jcode-tui-render/src/memory_tiles.rs new file mode 100644 index 000000000..850383154 --- /dev/null +++ b/crates/jcode-tui-render/src/memory_tiles.rs @@ -0,0 +1,587 @@ +use chrono::{DateTime, Utc}; +use ratatui::prelude::*; + +#[derive(Clone)] +pub struct MemoryTilePlan { + pub lines: Vec>, + pub width: usize, + pub height: usize, + pub score: usize, +} + +pub struct MemoryTile { + category: String, + items: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MemoryTileItem { + pub content: String, + pub updated_at: Option>, +} + +impl From for MemoryTileItem { + fn from(content: String) -> Self { + Self { + content, + updated_at: None, + } + } +} + +impl From<&str> for MemoryTileItem { + fn from(content: &str) -> Self { + Self::from(content.to_string()) + } +} + +pub fn parse_memory_display_entries(content: &str) -> Vec<(String, MemoryTileItem)> { + let mut entries: Vec<(String, MemoryTileItem)> = Vec::new(); + let mut current_category = String::new(); + let mut last_entry_idx: Option = None; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.starts_with("# ") || line.is_empty() { + continue; + } + if let Some(category) = line.strip_prefix("## ") { + current_category = category.trim().to_string(); + continue; + } + if let Some(updated_at_raw) = line + .strip_prefix("")) + { + if let (Some(idx), Ok(updated_at)) = ( + last_entry_idx, + DateTime::parse_from_rfc3339(updated_at_raw.trim()), + ) { + entries[idx].1.updated_at = Some(updated_at.with_timezone(&Utc)); + } + continue; + } + + let content = if let Some(dot_pos) = line.find(". ") { + let prefix = &line[..dot_pos]; + if prefix.trim().chars().all(|c| c.is_ascii_digit()) { + line[dot_pos + 2..].trim() + } else { + line + } + } else { + line + }; + if content.is_empty() { + continue; + } + + let category = if current_category.is_empty() { + "memory".to_string() + } else { + current_category.clone() + }; + entries.push(( + category, + MemoryTileItem { + content: content.to_string(), + updated_at: None, + }, + )); + last_entry_idx = Some(entries.len() - 1); + } + + entries +} + +pub fn group_into_tiles(entries: Vec<(String, T)>) -> Vec +where + T: Into, +{ + let mut order: Vec = Vec::new(); + let mut map: std::collections::HashMap> = + std::collections::HashMap::new(); + for (cat, content) in entries { + if !map.contains_key(&cat) { + order.push(cat.clone()); + } + map.entry(cat).or_default().push(content.into()); + } + order + .into_iter() + .filter_map(|cat| { + map.remove(&cat).map(|items| MemoryTile { + category: cat, + items, + }) + }) + .collect() +} + +/// Split a string into chunks that each fit within `max_width` display columns, +/// respecting multi-column characters (CJK characters take 2 columns, etc.). +pub fn split_by_display_width(s: &str, max_width: usize) -> Vec { + use unicode_width::UnicodeWidthChar; + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_width = 0usize; + + for ch in s.chars() { + let cw = UnicodeWidthChar::width(ch).unwrap_or(0); + if current_width + cw > max_width && !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + current_width = 0; + } + current.push(ch); + current_width += cw; + } + if !current.is_empty() { + chunks.push(current); + } + if chunks.is_empty() { + chunks.push(String::new()); + } + chunks +} + +fn truncate_to_display_width(s: &str, max_width: usize) -> String { + use unicode_width::UnicodeWidthChar; + + if max_width == 0 { + return String::new(); + } + + let full_width = unicode_width::UnicodeWidthStr::width(s); + if full_width <= max_width { + return s.to_string(); + } + + let ellipsis = "…"; + let ellipsis_width = unicode_width::UnicodeWidthStr::width(ellipsis); + if ellipsis_width >= max_width { + return ellipsis.to_string(); + } + + let target_width = max_width - ellipsis_width; + let mut truncated = String::new(); + let mut width = 0usize; + for ch in s.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > target_width { + break; + } + truncated.push(ch); + width += ch_width; + } + truncated.push('…'); + truncated +} + +fn format_memory_updated_age(updated_at: DateTime) -> String { + let age = Utc::now().signed_duration_since(updated_at); + if age.num_seconds() < 2 { + "updated now".to_string() + } else if age.num_minutes() < 1 { + format!("updated {}s ago", age.num_seconds().max(1)) + } else if age.num_hours() < 1 { + format!("updated {}m ago", age.num_minutes()) + } else if age.num_days() < 1 { + format!("updated {}h ago", age.num_hours()) + } else if age.num_days() < 7 { + format!("updated {}d ago", age.num_days()) + } else if age.num_days() < 30 { + format!("updated {}w ago", (age.num_days() / 7).max(1)) + } else { + format!("updated {}mo ago", (age.num_days() / 30).max(1)) + } +} + +fn memory_age_text_tint(updated_at: Option>) -> Color { + let Some(updated_at) = updated_at else { + return Color::Rgb(140, 144, 152); + }; + let age = Utc::now().signed_duration_since(updated_at); + if age.num_hours() < 1 { + Color::Rgb(146, 156, 149) + } else if age.num_days() < 1 { + Color::Rgb(142, 148, 156) + } else if age.num_days() < 7 { + Color::Rgb(145, 144, 154) + } else if age.num_days() < 30 { + Color::Rgb(150, 143, 147) + } else { + Color::Rgb(154, 144, 144) + } +} + +fn memory_tile_content_lines( + items: &[MemoryTileItem], + inner_width: usize, + border_style: Style, + text_style: Style, +) -> Vec> { + let bullet = "· "; + let bullet_width = unicode_width::UnicodeWidthStr::width(bullet); + let item_width = inner_width.saturating_sub(bullet_width); + + let mut content_lines: Vec> = Vec::new(); + for item in items { + let text_fill_style = text_style.fg(memory_age_text_tint(item.updated_at)); + let meta_fill_style = Style::default().fg(Color::Rgb(160, 165, 172)); + let text_display_width = unicode_width::UnicodeWidthStr::width(item.content.as_str()); + if text_display_width <= item_width { + let text = item.content.to_string(); + let padding = inner_width.saturating_sub(bullet_width + text_display_width); + let mut spans = vec![ + Span::styled("│ ", border_style), + Span::styled(bullet.to_string(), text_fill_style), + Span::styled(text, text_fill_style), + ]; + if padding > 0 { + spans.push(Span::raw(" ".repeat(padding))); + } + spans.push(Span::styled(" │", border_style)); + content_lines.push(Line::from(spans)); + } else { + let indent = bullet_width; + let cont_width = inner_width.saturating_sub(indent); + let first_chunk_width = item_width; + let mut all_chunks: Vec = Vec::new(); + let first_chunks = split_by_display_width(&item.content, first_chunk_width); + if let Some(first) = first_chunks.first() { + all_chunks.push(first.clone()); + let remainder: String = item.content.chars().skip(first.chars().count()).collect(); + if !remainder.is_empty() { + all_chunks.extend(split_by_display_width(&remainder, cont_width)); + } + } + for (ci, chunk) in all_chunks.iter().enumerate() { + let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str()); + if ci == 0 { + let padding = inner_width.saturating_sub(bullet_width + chunk_width); + let mut spans = vec![ + Span::styled("│ ", border_style), + Span::styled(bullet.to_string(), text_fill_style), + Span::styled(chunk.clone(), text_fill_style), + ]; + if padding > 0 { + spans.push(Span::raw(" ".repeat(padding))); + } + spans.push(Span::styled(" │", border_style)); + content_lines.push(Line::from(spans)); + } else { + let padding = inner_width.saturating_sub(indent + chunk_width); + let mut spans = vec![ + Span::styled("│ ", border_style), + Span::raw(" ".repeat(indent)), + Span::styled(chunk.clone(), text_fill_style), + ]; + if padding > 0 { + spans.push(Span::raw(" ".repeat(padding))); + } + spans.push(Span::styled(" │", border_style)); + content_lines.push(Line::from(spans)); + } + } + } + + if let Some(updated_at) = item.updated_at { + let meta = format_memory_updated_age(updated_at); + let indent = bullet_width; + let meta_width = inner_width.saturating_sub(indent).max(1); + for chunk in split_by_display_width(&meta, meta_width) { + let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str()); + let padding = inner_width.saturating_sub(indent + chunk_width); + content_lines.push(Line::from(vec![ + Span::styled("│ ", border_style), + Span::raw(" ".repeat(indent)), + Span::styled(chunk, meta_fill_style), + Span::raw(" ".repeat(padding)), + Span::styled(" │", border_style), + ])); + } + } + } + + if content_lines.is_empty() { + content_lines.push(Line::from(vec![ + Span::styled("│ ", border_style), + Span::raw(" ".repeat(inner_width)), + Span::styled(" │", border_style), + ])); + } + + content_lines +} + +fn render_memory_tile_box( + tile: &MemoryTile, + box_width: usize, + border_style: Style, + text_style: Style, +) -> Vec> { + let inner_width = box_width.saturating_sub(4); + if inner_width < 4 { + return Vec::new(); + } + + let title_max_width = box_width.saturating_sub(4); + let title_label = truncate_to_display_width(&tile.category.to_lowercase(), title_max_width); + let title_text = format!(" {} ", title_label); + let title_len = unicode_width::UnicodeWidthStr::width(title_text.as_str()); + let border_chars = box_width.saturating_sub(title_len + 2); + let left_border = "─".repeat(border_chars / 2); + let right_border = "─".repeat(border_chars - border_chars / 2); + + let top = Line::from(Span::styled( + format!("╭{}{}{}╮", left_border, title_text, right_border), + border_style, + )); + let content_lines = + memory_tile_content_lines(&tile.items, inner_width, border_style, text_style); + let bottom = Line::from(Span::styled( + format!("╰{}╯", "─".repeat(box_width.saturating_sub(2))), + border_style, + )); + + let mut lines = Vec::with_capacity(content_lines.len() + 2); + lines.push(top); + lines.extend(content_lines); + lines.push(bottom); + lines +} + +pub fn plan_memory_tile( + tile: &MemoryTile, + box_width: usize, + border_style: Style, + text_style: Style, +) -> Option { + let lines = render_memory_tile_box(tile, box_width, border_style, text_style); + if lines.is_empty() { + return None; + } + let width = lines.first().map(Line::width).unwrap_or(box_width); + let height = lines.len(); + let score = tile.items.len() * 10 + + tile + .items + .iter() + .map(|item| unicode_width::UnicodeWidthStr::width(item.content.as_str()).min(80)) + .sum::(); + Some(MemoryTilePlan { + lines, + width, + height, + score, + }) +} + +pub fn choose_memory_tile_span( + tile: &MemoryTile, + column_width: usize, + gap: usize, + max_span: usize, + border_style: Style, + text_style: Style, +) -> Option<(MemoryTilePlan, usize)> { + let single = plan_memory_tile(tile, column_width, border_style, text_style)?; + let mut best_plan = single.clone(); + let mut best_span = 1usize; + + for span in 2..=max_span.max(1) { + let width = column_width * span + gap * span.saturating_sub(1); + let Some(plan) = plan_memory_tile(tile, width, border_style, text_style) else { + continue; + }; + + let single_area = single.width * single.height; + let span_area = plan.width * plan.height; + let height_gain = single.height.saturating_sub(plan.height); + let area_gain = single_area.saturating_sub(span_area); + + if height_gain >= 2 || (height_gain >= 1 && area_gain > column_width) { + best_plan = plan; + best_span = span; + break; + } + } + + Some((best_plan, best_span)) +} + +pub fn render_memory_tiles( + tiles: &[MemoryTile], + total_width: usize, + border_style: Style, + text_style: Style, + header_line: Option>, +) -> Vec> { + if tiles.is_empty() { + return Vec::new(); + } + + let mut all_lines: Vec> = Vec::new(); + + if let Some(header) = header_line { + all_lines.push(header); + } + + let min_box_inner = 16usize; + let min_box_width = min_box_inner + 4; + let gap = 2usize; + let row_gap = 0usize; + let usable_width = total_width.max(min_box_width); + + #[derive(Clone)] + struct Placement { + x: usize, + y: usize, + plan: MemoryTilePlan, + } + + #[derive(Clone)] + struct PlannedTile { + span: usize, + plan: MemoryTilePlan, + } + + let max_cols = ((usable_width + gap) / (min_box_width + gap)).clamp(1, 4); + let mut best_layout: Option<(Vec, usize, usize)> = None; + + for column_count in 1..=max_cols { + let column_width = (usable_width.saturating_sub((column_count - 1) * gap)) / column_count; + if column_width < min_box_width { + continue; + } + + let max_span = if column_count >= 2 { 2 } else { 1 }; + let mut planned: Vec = tiles + .iter() + .filter_map(|tile| { + let (plan, span) = choose_memory_tile_span( + tile, + column_width, + gap, + max_span, + border_style, + text_style, + )?; + Some(PlannedTile { span, plan }) + }) + .collect(); + + if planned.is_empty() { + continue; + } + + planned.sort_by(|a, b| { + b.plan + .score + .cmp(&a.plan.score) + .then_with(|| b.span.cmp(&a.span)) + .then_with(|| b.plan.height.cmp(&a.plan.height)) + .then_with(|| b.plan.width.cmp(&a.plan.width)) + }); + + let mut column_heights = vec![0usize; column_count]; + let mut placements: Vec = Vec::with_capacity(planned.len()); + + for planned_tile in planned { + let mut best_start = 0usize; + let mut best_y = usize::MAX; + + for start_col in 0..=column_count.saturating_sub(planned_tile.span) { + let y = column_heights[start_col..start_col + planned_tile.span] + .iter() + .copied() + .max() + .unwrap_or(0); + + if y < best_y || (y == best_y && start_col < best_start) { + best_start = start_col; + best_y = y; + } + } + + let x = best_start * (column_width + gap); + let next_height = best_y + planned_tile.plan.height + row_gap; + for height in &mut column_heights[best_start..best_start + planned_tile.span] { + *height = next_height; + } + + placements.push(Placement { + x, + y: best_y, + plan: planned_tile.plan, + }); + } + + let total_height = column_heights + .iter() + .copied() + .max() + .unwrap_or(0) + .saturating_sub(row_gap); + let imbalance = column_heights.iter().copied().max().unwrap_or(0) + - column_heights.iter().copied().min().unwrap_or(0); + let used_width = column_count * column_width + gap * column_count.saturating_sub(1); + let leftover_width = usable_width.saturating_sub(used_width); + + // Vertical centering: if this column arrangement has imbalanced columns, + // center shorter columns' tiles vertically within the available space. + let max_col_height = *column_heights.iter().max().unwrap_or(&0); + for (col_idx, col_height) in column_heights.iter().enumerate() { + if *col_height < max_col_height { + let extra = max_col_height - col_height; + let offset = extra / 2; + if offset > 0 { + for placed in placements.iter_mut() { + let start_col = placed.x / (column_width + gap); + if start_col == col_idx { + placed.y += offset; + } + } + } + } + } + + let layout_score = total_height * 100 + imbalance * 3 + leftover_width; + + match &best_layout { + Some((_, _, best_score)) if *best_score <= layout_score => {} + _ => best_layout = Some((placements, total_height, layout_score)), + } + } + + let Some((mut placements, total_height, _)) = best_layout else { + return all_lines; + }; + + placements.sort_by(|a, b| a.x.cmp(&b.x).then_with(|| a.y.cmp(&b.y))); + + for y in 0..total_height { + let mut spans: Vec> = Vec::new(); + let mut cursor = 0usize; + let mut row_has_content = false; + for placed in placements + .iter() + .filter(|placed| y >= placed.y && y < placed.y + placed.plan.height) + { + if placed.x > cursor { + spans.push(Span::raw(" ".repeat(placed.x - cursor))); + } + spans.extend(placed.plan.lines[y - placed.y].spans.clone()); + cursor = placed.x + placed.plan.width; + row_has_content = true; + } + if row_has_content { + if cursor < usable_width { + spans.push(Span::raw(" ".repeat(usable_width - cursor))); + } + all_lines.push(Line::from(spans)); + } + } + + all_lines +} diff --git a/crates/jcode-tui/src/tui/ui_memory.rs b/crates/jcode-tui/src/tui/ui_memory.rs index c66ef292d..9b03d7aa2 100644 --- a/crates/jcode-tui/src/tui/ui_memory.rs +++ b/crates/jcode-tui/src/tui/ui_memory.rs @@ -1,587 +1 @@ -use chrono::{DateTime, Utc}; -use ratatui::prelude::*; - -#[derive(Clone)] -pub(super) struct MemoryTilePlan { - pub(super) lines: Vec>, - pub(super) width: usize, - pub(super) height: usize, - pub(super) score: usize, -} - -pub(super) struct MemoryTile { - category: String, - items: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct MemoryTileItem { - pub(super) content: String, - pub(super) updated_at: Option>, -} - -impl From for MemoryTileItem { - fn from(content: String) -> Self { - Self { - content, - updated_at: None, - } - } -} - -impl From<&str> for MemoryTileItem { - fn from(content: &str) -> Self { - Self::from(content.to_string()) - } -} - -pub(super) fn parse_memory_display_entries(content: &str) -> Vec<(String, MemoryTileItem)> { - let mut entries: Vec<(String, MemoryTileItem)> = Vec::new(); - let mut current_category = String::new(); - let mut last_entry_idx: Option = None; - - for raw_line in content.lines() { - let line = raw_line.trim(); - if line.starts_with("# ") || line.is_empty() { - continue; - } - if let Some(category) = line.strip_prefix("## ") { - current_category = category.trim().to_string(); - continue; - } - if let Some(updated_at_raw) = line - .strip_prefix("")) - { - if let (Some(idx), Ok(updated_at)) = ( - last_entry_idx, - DateTime::parse_from_rfc3339(updated_at_raw.trim()), - ) { - entries[idx].1.updated_at = Some(updated_at.with_timezone(&Utc)); - } - continue; - } - - let content = if let Some(dot_pos) = line.find(". ") { - let prefix = &line[..dot_pos]; - if prefix.trim().chars().all(|c| c.is_ascii_digit()) { - line[dot_pos + 2..].trim() - } else { - line - } - } else { - line - }; - if content.is_empty() { - continue; - } - - let category = if current_category.is_empty() { - "memory".to_string() - } else { - current_category.clone() - }; - entries.push(( - category, - MemoryTileItem { - content: content.to_string(), - updated_at: None, - }, - )); - last_entry_idx = Some(entries.len() - 1); - } - - entries -} - -pub(super) fn group_into_tiles(entries: Vec<(String, T)>) -> Vec -where - T: Into, -{ - let mut order: Vec = Vec::new(); - let mut map: std::collections::HashMap> = - std::collections::HashMap::new(); - for (cat, content) in entries { - if !map.contains_key(&cat) { - order.push(cat.clone()); - } - map.entry(cat).or_default().push(content.into()); - } - order - .into_iter() - .filter_map(|cat| { - map.remove(&cat).map(|items| MemoryTile { - category: cat, - items, - }) - }) - .collect() -} - -/// Split a string into chunks that each fit within `max_width` display columns, -/// respecting multi-column characters (CJK characters take 2 columns, etc.). -pub(super) fn split_by_display_width(s: &str, max_width: usize) -> Vec { - use unicode_width::UnicodeWidthChar; - let mut chunks = Vec::new(); - let mut current = String::new(); - let mut current_width = 0usize; - - for ch in s.chars() { - let cw = UnicodeWidthChar::width(ch).unwrap_or(0); - if current_width + cw > max_width && !current.is_empty() { - chunks.push(std::mem::take(&mut current)); - current_width = 0; - } - current.push(ch); - current_width += cw; - } - if !current.is_empty() { - chunks.push(current); - } - if chunks.is_empty() { - chunks.push(String::new()); - } - chunks -} - -fn truncate_to_display_width(s: &str, max_width: usize) -> String { - use unicode_width::UnicodeWidthChar; - - if max_width == 0 { - return String::new(); - } - - let full_width = unicode_width::UnicodeWidthStr::width(s); - if full_width <= max_width { - return s.to_string(); - } - - let ellipsis = "…"; - let ellipsis_width = unicode_width::UnicodeWidthStr::width(ellipsis); - if ellipsis_width >= max_width { - return ellipsis.to_string(); - } - - let target_width = max_width - ellipsis_width; - let mut truncated = String::new(); - let mut width = 0usize; - for ch in s.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > target_width { - break; - } - truncated.push(ch); - width += ch_width; - } - truncated.push('…'); - truncated -} - -fn format_memory_updated_age(updated_at: DateTime) -> String { - let age = Utc::now().signed_duration_since(updated_at); - if age.num_seconds() < 2 { - "updated now".to_string() - } else if age.num_minutes() < 1 { - format!("updated {}s ago", age.num_seconds().max(1)) - } else if age.num_hours() < 1 { - format!("updated {}m ago", age.num_minutes()) - } else if age.num_days() < 1 { - format!("updated {}h ago", age.num_hours()) - } else if age.num_days() < 7 { - format!("updated {}d ago", age.num_days()) - } else if age.num_days() < 30 { - format!("updated {}w ago", (age.num_days() / 7).max(1)) - } else { - format!("updated {}mo ago", (age.num_days() / 30).max(1)) - } -} - -fn memory_age_text_tint(updated_at: Option>) -> Color { - let Some(updated_at) = updated_at else { - return Color::Rgb(140, 144, 152); - }; - let age = Utc::now().signed_duration_since(updated_at); - if age.num_hours() < 1 { - Color::Rgb(146, 156, 149) - } else if age.num_days() < 1 { - Color::Rgb(142, 148, 156) - } else if age.num_days() < 7 { - Color::Rgb(145, 144, 154) - } else if age.num_days() < 30 { - Color::Rgb(150, 143, 147) - } else { - Color::Rgb(154, 144, 144) - } -} - -fn memory_tile_content_lines( - items: &[MemoryTileItem], - inner_width: usize, - border_style: Style, - text_style: Style, -) -> Vec> { - let bullet = "· "; - let bullet_width = unicode_width::UnicodeWidthStr::width(bullet); - let item_width = inner_width.saturating_sub(bullet_width); - - let mut content_lines: Vec> = Vec::new(); - for item in items { - let text_fill_style = text_style.fg(memory_age_text_tint(item.updated_at)); - let meta_fill_style = Style::default().fg(Color::Rgb(160, 165, 172)); - let text_display_width = unicode_width::UnicodeWidthStr::width(item.content.as_str()); - if text_display_width <= item_width { - let text = item.content.to_string(); - let padding = inner_width.saturating_sub(bullet_width + text_display_width); - let mut spans = vec![ - Span::styled("│ ", border_style), - Span::styled(bullet.to_string(), text_fill_style), - Span::styled(text, text_fill_style), - ]; - if padding > 0 { - spans.push(Span::raw(" ".repeat(padding))); - } - spans.push(Span::styled(" │", border_style)); - content_lines.push(Line::from(spans)); - } else { - let indent = bullet_width; - let cont_width = inner_width.saturating_sub(indent); - let first_chunk_width = item_width; - let mut all_chunks: Vec = Vec::new(); - let first_chunks = split_by_display_width(&item.content, first_chunk_width); - if let Some(first) = first_chunks.first() { - all_chunks.push(first.clone()); - let remainder: String = item.content.chars().skip(first.chars().count()).collect(); - if !remainder.is_empty() { - all_chunks.extend(split_by_display_width(&remainder, cont_width)); - } - } - for (ci, chunk) in all_chunks.iter().enumerate() { - let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str()); - if ci == 0 { - let padding = inner_width.saturating_sub(bullet_width + chunk_width); - let mut spans = vec![ - Span::styled("│ ", border_style), - Span::styled(bullet.to_string(), text_fill_style), - Span::styled(chunk.clone(), text_fill_style), - ]; - if padding > 0 { - spans.push(Span::raw(" ".repeat(padding))); - } - spans.push(Span::styled(" │", border_style)); - content_lines.push(Line::from(spans)); - } else { - let padding = inner_width.saturating_sub(indent + chunk_width); - let mut spans = vec![ - Span::styled("│ ", border_style), - Span::raw(" ".repeat(indent)), - Span::styled(chunk.clone(), text_fill_style), - ]; - if padding > 0 { - spans.push(Span::raw(" ".repeat(padding))); - } - spans.push(Span::styled(" │", border_style)); - content_lines.push(Line::from(spans)); - } - } - } - - if let Some(updated_at) = item.updated_at { - let meta = format_memory_updated_age(updated_at); - let indent = bullet_width; - let meta_width = inner_width.saturating_sub(indent).max(1); - for chunk in split_by_display_width(&meta, meta_width) { - let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str()); - let padding = inner_width.saturating_sub(indent + chunk_width); - content_lines.push(Line::from(vec![ - Span::styled("│ ", border_style), - Span::raw(" ".repeat(indent)), - Span::styled(chunk, meta_fill_style), - Span::raw(" ".repeat(padding)), - Span::styled(" │", border_style), - ])); - } - } - } - - if content_lines.is_empty() { - content_lines.push(Line::from(vec![ - Span::styled("│ ", border_style), - Span::raw(" ".repeat(inner_width)), - Span::styled(" │", border_style), - ])); - } - - content_lines -} - -fn render_memory_tile_box( - tile: &MemoryTile, - box_width: usize, - border_style: Style, - text_style: Style, -) -> Vec> { - let inner_width = box_width.saturating_sub(4); - if inner_width < 4 { - return Vec::new(); - } - - let title_max_width = box_width.saturating_sub(4); - let title_label = truncate_to_display_width(&tile.category.to_lowercase(), title_max_width); - let title_text = format!(" {} ", title_label); - let title_len = unicode_width::UnicodeWidthStr::width(title_text.as_str()); - let border_chars = box_width.saturating_sub(title_len + 2); - let left_border = "─".repeat(border_chars / 2); - let right_border = "─".repeat(border_chars - border_chars / 2); - - let top = Line::from(Span::styled( - format!("╭{}{}{}╮", left_border, title_text, right_border), - border_style, - )); - let content_lines = - memory_tile_content_lines(&tile.items, inner_width, border_style, text_style); - let bottom = Line::from(Span::styled( - format!("╰{}╯", "─".repeat(box_width.saturating_sub(2))), - border_style, - )); - - let mut lines = Vec::with_capacity(content_lines.len() + 2); - lines.push(top); - lines.extend(content_lines); - lines.push(bottom); - lines -} - -pub(super) fn plan_memory_tile( - tile: &MemoryTile, - box_width: usize, - border_style: Style, - text_style: Style, -) -> Option { - let lines = render_memory_tile_box(tile, box_width, border_style, text_style); - if lines.is_empty() { - return None; - } - let width = lines.first().map(Line::width).unwrap_or(box_width); - let height = lines.len(); - let score = tile.items.len() * 10 - + tile - .items - .iter() - .map(|item| unicode_width::UnicodeWidthStr::width(item.content.as_str()).min(80)) - .sum::(); - Some(MemoryTilePlan { - lines, - width, - height, - score, - }) -} - -pub(super) fn choose_memory_tile_span( - tile: &MemoryTile, - column_width: usize, - gap: usize, - max_span: usize, - border_style: Style, - text_style: Style, -) -> Option<(MemoryTilePlan, usize)> { - let single = plan_memory_tile(tile, column_width, border_style, text_style)?; - let mut best_plan = single.clone(); - let mut best_span = 1usize; - - for span in 2..=max_span.max(1) { - let width = column_width * span + gap * span.saturating_sub(1); - let Some(plan) = plan_memory_tile(tile, width, border_style, text_style) else { - continue; - }; - - let single_area = single.width * single.height; - let span_area = plan.width * plan.height; - let height_gain = single.height.saturating_sub(plan.height); - let area_gain = single_area.saturating_sub(span_area); - - if height_gain >= 2 || (height_gain >= 1 && area_gain > column_width) { - best_plan = plan; - best_span = span; - break; - } - } - - Some((best_plan, best_span)) -} - -pub(super) fn render_memory_tiles( - tiles: &[MemoryTile], - total_width: usize, - border_style: Style, - text_style: Style, - header_line: Option>, -) -> Vec> { - if tiles.is_empty() { - return Vec::new(); - } - - let mut all_lines: Vec> = Vec::new(); - - if let Some(header) = header_line { - all_lines.push(header); - } - - let min_box_inner = 16usize; - let min_box_width = min_box_inner + 4; - let gap = 2usize; - let row_gap = 0usize; - let usable_width = total_width.max(min_box_width); - - #[derive(Clone)] - struct Placement { - x: usize, - y: usize, - plan: MemoryTilePlan, - } - - #[derive(Clone)] - struct PlannedTile { - span: usize, - plan: MemoryTilePlan, - } - - let max_cols = ((usable_width + gap) / (min_box_width + gap)).clamp(1, 4); - let mut best_layout: Option<(Vec, usize, usize)> = None; - - for column_count in 1..=max_cols { - let column_width = (usable_width.saturating_sub((column_count - 1) * gap)) / column_count; - if column_width < min_box_width { - continue; - } - - let max_span = if column_count >= 2 { 2 } else { 1 }; - let mut planned: Vec = tiles - .iter() - .filter_map(|tile| { - let (plan, span) = choose_memory_tile_span( - tile, - column_width, - gap, - max_span, - border_style, - text_style, - )?; - Some(PlannedTile { span, plan }) - }) - .collect(); - - if planned.is_empty() { - continue; - } - - planned.sort_by(|a, b| { - b.plan - .score - .cmp(&a.plan.score) - .then_with(|| b.span.cmp(&a.span)) - .then_with(|| b.plan.height.cmp(&a.plan.height)) - .then_with(|| b.plan.width.cmp(&a.plan.width)) - }); - - let mut column_heights = vec![0usize; column_count]; - let mut placements: Vec = Vec::with_capacity(planned.len()); - - for planned_tile in planned { - let mut best_start = 0usize; - let mut best_y = usize::MAX; - - for start_col in 0..=column_count.saturating_sub(planned_tile.span) { - let y = column_heights[start_col..start_col + planned_tile.span] - .iter() - .copied() - .max() - .unwrap_or(0); - - if y < best_y || (y == best_y && start_col < best_start) { - best_start = start_col; - best_y = y; - } - } - - let x = best_start * (column_width + gap); - let next_height = best_y + planned_tile.plan.height + row_gap; - for height in &mut column_heights[best_start..best_start + planned_tile.span] { - *height = next_height; - } - - placements.push(Placement { - x, - y: best_y, - plan: planned_tile.plan, - }); - } - - let total_height = column_heights - .iter() - .copied() - .max() - .unwrap_or(0) - .saturating_sub(row_gap); - let imbalance = column_heights.iter().copied().max().unwrap_or(0) - - column_heights.iter().copied().min().unwrap_or(0); - let used_width = column_count * column_width + gap * column_count.saturating_sub(1); - let leftover_width = usable_width.saturating_sub(used_width); - - // Vertical centering: if this column arrangement has imbalanced columns, - // center shorter columns' tiles vertically within the available space. - let max_col_height = *column_heights.iter().max().unwrap_or(&0); - for (col_idx, col_height) in column_heights.iter().enumerate() { - if *col_height < max_col_height { - let extra = max_col_height - col_height; - let offset = extra / 2; - if offset > 0 { - for placed in placements.iter_mut() { - let start_col = placed.x / (column_width + gap); - if start_col == col_idx { - placed.y += offset; - } - } - } - } - } - - let layout_score = total_height * 100 + imbalance * 3 + leftover_width; - - match &best_layout { - Some((_, _, best_score)) if *best_score <= layout_score => {} - _ => best_layout = Some((placements, total_height, layout_score)), - } - } - - let Some((mut placements, total_height, _)) = best_layout else { - return all_lines; - }; - - placements.sort_by(|a, b| a.x.cmp(&b.x).then_with(|| a.y.cmp(&b.y))); - - for y in 0..total_height { - let mut spans: Vec> = Vec::new(); - let mut cursor = 0usize; - let mut row_has_content = false; - for placed in placements - .iter() - .filter(|placed| y >= placed.y && y < placed.y + placed.plan.height) - { - if placed.x > cursor { - spans.push(Span::raw(" ".repeat(placed.x - cursor))); - } - spans.extend(placed.plan.lines[y - placed.y].spans.clone()); - cursor = placed.x + placed.plan.width; - row_has_content = true; - } - if row_has_content { - if cursor < usable_width { - spans.push(Span::raw(" ".repeat(usable_width - cursor))); - } - all_lines.push(Line::from(spans)); - } - } - - all_lines -} +pub(crate) use jcode_tui_render::memory_tiles::*; From 21bbb2e3c6ae1f9c5072d0f7115dc2116c949387 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:58:59 -0700 Subject: [PATCH 23/41] refactor(tui): isolate visual debug support crate --- Cargo.lock | 13 + Cargo.toml | 1 + crates/jcode-tui-visual-debug/Cargo.toml | 17 + crates/jcode-tui-visual-debug/src/lib.rs | 857 ++++++++++++++++++++++ crates/jcode-tui/Cargo.toml | 1 + crates/jcode-tui/src/tui/visual_debug.rs | 863 +---------------------- 6 files changed, 897 insertions(+), 855 deletions(-) create mode 100644 crates/jcode-tui-visual-debug/Cargo.toml create mode 100644 crates/jcode-tui-visual-debug/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index cc40e318c..12ddbdda7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4011,6 +4011,7 @@ dependencies = [ "jcode-tui-style", "jcode-tui-tool-display", "jcode-tui-usage-overlay", + "jcode-tui-visual-debug", "jcode-tui-workspace", "libc", "open", @@ -4134,6 +4135,18 @@ dependencies = [ "serde", ] +[[package]] +name = "jcode-tui-visual-debug" +version = "0.1.0" +dependencies = [ + "dirs", + "jcode-logging", + "ratatui", + "regex", + "serde", + "serde_json", +] + [[package]] name = "jcode-tui-workspace" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8635279b6..065cfef67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ members = [ "crates/jcode-tui-account-picker", "crates/jcode-tui-anim", "crates/jcode-tui-render", + "crates/jcode-tui-visual-debug", "crates/jcode-tui-session-picker", "crates/jcode-tui-style", "crates/jcode-tui-tool-display", diff --git a/crates/jcode-tui-visual-debug/Cargo.toml b/crates/jcode-tui-visual-debug/Cargo.toml new file mode 100644 index 000000000..6290a9253 --- /dev/null +++ b/crates/jcode-tui-visual-debug/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "jcode-tui-visual-debug" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "jcode_tui_visual_debug" +path = "src/lib.rs" + +[dependencies] +dirs = "5" +jcode-logging = { path = "../jcode-logging" } +ratatui = "0.30" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value"] } diff --git a/crates/jcode-tui-visual-debug/src/lib.rs b/crates/jcode-tui-visual-debug/src/lib.rs new file mode 100644 index 000000000..17f7bec85 --- /dev/null +++ b/crates/jcode-tui-visual-debug/src/lib.rs @@ -0,0 +1,857 @@ +//! Visual Debug Infrastructure +//! +//! Captures TUI frame state for autonomous debugging by AI agents. +//! When enabled, writes detailed render information to a debug file +//! that can be read to understand visual bugs without seeing the terminal. + +use std::collections::VecDeque; +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use ratatui::layout::Rect; +use serde::Serialize; +use serde_json::Value; + +/// Global flag to enable visual debugging (set via /debug-visual command) +static VISUAL_DEBUG_ENABLED: AtomicBool = AtomicBool::new(false); +/// Global flag to enable overlay drawing +static VISUAL_DEBUG_OVERLAY: AtomicBool = AtomicBool::new(false); + +/// Maximum number of frames to keep in the ring buffer +const MAX_FRAMES: usize = 100; + +/// Global frame buffer +static FRAME_BUFFER: OnceLock> = OnceLock::new(); + +fn get_frame_buffer() -> &'static Mutex { + FRAME_BUFFER.get_or_init(|| Mutex::new(FrameBuffer::new())) +} + +/// A captured frame with all render context +#[derive(Debug, Clone, Serialize)] +pub struct FrameCapture { + /// Frame number (monotonically increasing) + pub frame_id: u64, + /// Timestamp when frame was rendered + pub timestamp: std::time::SystemTime, + /// Terminal dimensions + pub terminal_size: (u16, u16), + /// Layout areas computed for this frame + pub layout: LayoutCapture, + /// State snapshot at render time + pub state: StateSnapshot, + /// Any anomalies detected during rendering + pub anomalies: Vec, + /// The actual text content rendered to each area (stripped of ANSI) + pub rendered_text: RenderedText, + /// Mermaid image regions detected in wrapped content + pub image_regions: Vec, + /// Render timing information (milliseconds) + pub render_timing: Option, + /// Info widget placements and summary data + pub info_widgets: Option, + /// Render order for major phases + pub render_order: Vec, + /// Mermaid debug stats snapshot (if available) + pub mermaid: Option, + /// Side-panel debug snapshot, including live Mermaid utilization when available + pub side_panel: Option, + /// Markdown debug stats snapshot (if available) + pub markdown: Option, + /// Theme/palette snapshot (if available) + pub theme: Option, +} + +/// Captured layout computation +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct LayoutCapture { + /// Whether packed layout was used (vs scrolling) + pub use_packed: bool, + /// Estimated content height + pub estimated_content_height: usize, + /// Messages area + pub messages_area: Option, + /// Diagram area (pinned diagram pane) + pub diagram_area: Option, + /// Status line area + pub status_area: Option, + /// Queued messages area + pub queued_area: Option, + /// Input area + pub input_area: Option, + /// Input line count (before wrapping) + pub input_lines_raw: usize, + /// Input line count (after wrapping) + pub input_lines_wrapped: usize, + /// Margin widths for info widgets (per visible row) + pub margins: Option, + /// Info widget placements + pub widget_placements: Vec, +} + +/// Rect capture (serializable) +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] +pub struct RectCapture { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +/// Margin widths captured for debug +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct MarginsCapture { + pub left_widths: Vec, + pub right_widths: Vec, + pub centered: bool, +} + +/// Info widget placement capture +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct WidgetPlacementCapture { + pub kind: String, + pub side: String, + pub rect: RectCapture, +} + +/// Render timing capture (milliseconds) +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct RenderTimingCapture { + pub prepare_ms: f32, + pub draw_ms: f32, + pub total_ms: f32, + pub messages_ms: Option, + pub widgets_ms: Option, +} + +/// Info widget summary capture +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct InfoWidgetSummary { + pub todos_total: usize, + pub todos_done: usize, + pub context_total_chars: Option, + pub context_limit: Option, + pub queue_mode: Option, + pub model: Option, + pub reasoning_effort: Option, + pub session_count: Option, + pub client_count: Option, + pub memory_total: Option, + pub memory_project: Option, + pub memory_global: Option, + pub memory_activity: Option, + pub swarm_session_count: Option, + pub swarm_member_count: Option, + pub swarm_subagent_status: Option, + pub background_running: Option, + pub background_tasks: Option, + pub usage_available: Option, + pub usage_provider: Option, + pub tokens_per_second: Option, + pub auth_method: Option, + pub upstream_provider: Option, +} + +/// Info widget capture (summary + placements) +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct InfoWidgetCapture { + pub summary: InfoWidgetSummary, + pub placements: Vec, +} + +impl From for RectCapture { + fn from(r: Rect) -> Self { + Self { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + } + } +} + +/// State snapshot at render time +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct StateSnapshot { + pub is_processing: bool, + pub input_len: usize, + pub input_preview: String, + pub cursor_pos: usize, + pub scroll_offset: usize, + pub queued_count: usize, + pub message_count: usize, + pub streaming_text_len: usize, + pub has_suggestions: bool, + pub status: String, + pub diagram_mode: Option, + pub diagram_focus: bool, + pub diagram_index: usize, + pub diagram_count: usize, + pub diagram_scroll_x: i32, + pub diagram_scroll_y: i32, + pub diagram_pane_ratio: u8, + pub diagram_pane_enabled: bool, + pub diagram_pane_position: Option, + pub diagram_zoom: u8, +} + +/// Actual rendered text content +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct RenderedText { + /// Status line text (spinner, tokens, elapsed, etc.) + pub status_line: String, + /// Input area text (what the user is typing) + pub input_area: String, + /// Hint text shown above input (if any) + pub input_hint: Option, + /// Queued messages (messages waiting to be sent) + pub queued_messages: Vec, + /// Recent messages displayed (last few for context) + pub recent_messages: Vec, + /// Streaming text (if currently streaming) + pub streaming_text_preview: String, +} + +/// Mermaid image region capture +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct ImageRegionCapture { + pub hash: String, + pub abs_line_idx: usize, + pub height: u16, +} + +/// Captured message for debugging +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct MessageCapture { + pub role: String, + pub content_preview: String, + pub content_len: usize, +} + +/// Ring buffer of recent frames +struct FrameBuffer { + frames: VecDeque, + next_frame_id: u64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct VisualDebugMemoryProfile { + pub enabled: bool, + pub overlay_enabled: bool, + pub frames_in_buffer: usize, + pub max_frames: usize, + pub total_frames_captured: u64, + pub anomalous_frames_in_buffer: usize, + pub frame_json_estimate_bytes: usize, +} + +impl FrameBuffer { + fn new() -> Self { + Self { + frames: VecDeque::with_capacity(MAX_FRAMES), + next_frame_id: 0, + } + } + + fn push(&mut self, mut frame: FrameCapture) { + frame.frame_id = self.next_frame_id; + self.next_frame_id += 1; + + if self.frames.len() >= MAX_FRAMES { + self.frames.pop_front(); + } + self.frames.push_back(frame); + } + + fn recent(&self, count: usize) -> Vec<&FrameCapture> { + self.frames.iter().rev().take(count).collect() + } + + fn frames_with_anomalies(&self) -> Vec<&FrameCapture> { + self.frames + .iter() + .filter(|f| !f.anomalies.is_empty()) + .collect() + } +} + +/// Enable visual debugging +pub fn enable() { + VISUAL_DEBUG_ENABLED.store(true, Ordering::SeqCst); + jcode_logging::info("Visual debugging enabled"); +} + +/// Disable visual debugging +pub fn disable() { + VISUAL_DEBUG_ENABLED.store(false, Ordering::SeqCst); +} + +/// Enable or disable overlay drawing +pub fn set_overlay(enabled: bool) { + VISUAL_DEBUG_OVERLAY.store(enabled, Ordering::SeqCst); +} + +/// Check if overlay drawing is enabled +pub fn overlay_enabled() -> bool { + VISUAL_DEBUG_OVERLAY.load(Ordering::SeqCst) +} + +/// Check if visual debugging is enabled +pub fn is_enabled() -> bool { + VISUAL_DEBUG_ENABLED.load(Ordering::SeqCst) +} + +/// Record a frame capture (skips if identical to previous frame) +pub fn record_frame(frame: FrameCapture) { + if !is_enabled() { + return; + } + + let mut buffer = get_frame_buffer() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + // Skip duplicate frames - only capture when something changes + // Always capture frames with anomalies + if let Some(last) = buffer.frames.back() { + let dominated = frame.state == last.state + && frame.rendered_text == last.rendered_text + && frame.layout == last.layout + && frame.info_widgets == last.info_widgets + && frame.side_panel == last.side_panel + && frame.anomalies.is_empty(); + if dominated { + return; + } + } + + buffer.push(frame); +} + +/// Get the debug output path +fn debug_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("jcode") + .join("visual-debug.txt") +} + +/// Dump recent frames to the debug file +pub fn dump_to_file() -> std::io::Result { + let path = debug_path(); + + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let buffer = get_frame_buffer() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut file = File::create(&path)?; + + writeln!(file, "=== JCODE VISUAL DEBUG DUMP ===")?; + writeln!(file, "Generated: {:?}", std::time::SystemTime::now())?; + writeln!(file, "Total frames captured: {}", buffer.next_frame_id)?; + writeln!(file, "Frames in buffer: {}", buffer.frames.len())?; + writeln!(file)?; + + // First, show frames with anomalies + let anomaly_frames = buffer.frames_with_anomalies(); + if !anomaly_frames.is_empty() { + writeln!( + file, + "=== FRAMES WITH ANOMALIES ({}) ===", + anomaly_frames.len() + )?; + for frame in anomaly_frames { + write_frame(&mut file, frame)?; + } + writeln!(file)?; + } + + // Then show recent frames + writeln!(file, "=== RECENT FRAMES (last 20) ===")?; + for frame in buffer.recent(20) { + write_frame(&mut file, frame)?; + } + + Ok(path) +} + +/// Return the most recent frame capture. +pub fn latest_frame() -> Option { + let buffer = get_frame_buffer().lock().ok()?; + buffer.frames.back().cloned() +} + +/// Return the most recent frame as a JSON string. +pub fn latest_frame_json() -> Option { + let frame = latest_frame()?; + serde_json::to_string_pretty(&frame).ok() +} + +/// Return the most recent frame as a normalized JSON string (for stable diffs). +/// Strips timestamps, UUIDs, session IDs, and other non-deterministic values. +pub fn latest_frame_json_normalized() -> Option { + let frame = latest_frame()?; + let normalized = normalize_frame(&frame); + serde_json::to_string_pretty(&normalized).ok() +} + +fn estimate_json_bytes(value: &T) -> usize { + serde_json::to_vec(value) + .map(|bytes| bytes.len()) + .unwrap_or(0) +} + +pub fn debug_memory_profile() -> VisualDebugMemoryProfile { + let Ok(buffer) = get_frame_buffer().lock() else { + return VisualDebugMemoryProfile { + enabled: is_enabled(), + overlay_enabled: overlay_enabled(), + max_frames: MAX_FRAMES, + ..VisualDebugMemoryProfile::default() + }; + }; + + VisualDebugMemoryProfile { + enabled: is_enabled(), + overlay_enabled: overlay_enabled(), + frames_in_buffer: buffer.frames.len(), + max_frames: MAX_FRAMES, + total_frames_captured: buffer.next_frame_id, + anomalous_frames_in_buffer: buffer + .frames + .iter() + .filter(|f| !f.anomalies.is_empty()) + .count(), + frame_json_estimate_bytes: buffer.frames.iter().map(estimate_json_bytes).sum(), + } +} + +/// Normalize a frame capture for stable comparisons. +/// Replaces timestamps, UUIDs, session IDs, and other volatile values with placeholders. +pub fn normalize_frame(frame: &FrameCapture) -> serde_json::Value { + let json = serde_json::to_value(frame).unwrap_or(serde_json::Value::Null); + normalize_json_value(json) +} + +/// Recursively normalize JSON values, replacing volatile content. +fn normalize_json_value(value: serde_json::Value) -> serde_json::Value { + use serde_json::Value; + + match value { + Value::String(s) => Value::String(normalize_string(&s)), + Value::Array(arr) => Value::Array(arr.into_iter().map(normalize_json_value).collect()), + Value::Object(map) => { + let mut new_map = serde_json::Map::new(); + for (k, v) in map { + // Skip timestamp fields entirely or normalize them + if k == "timestamp" || k == "created_at" || k == "updated_at" { + new_map.insert(k, Value::String("".to_string())); + } else if k == "frame_id" { + // Keep frame_id but note it's sequential + new_map.insert(k, v); + } else { + new_map.insert(k, normalize_json_value(v)); + } + } + Value::Object(new_map) + } + other => other, + } +} + +/// Normalize a string by replacing volatile patterns with placeholders. +fn normalize_string(s: &str) -> String { + use regex::Regex; + use std::sync::OnceLock; + + fn compile_regex(pattern: &str) -> Option { + match Regex::new(pattern) { + Ok(regex) => Some(regex), + Err(err) => { + jcode_logging::warn(&format!( + "visual_debug: failed to compile normalization regex: {}", + err + )); + None + } + } + } + + // Cached regex patterns for performance + static UUID_RE: OnceLock> = OnceLock::new(); + static SESSION_ID_RE: OnceLock> = OnceLock::new(); + static TIMESTAMP_RE: OnceLock> = OnceLock::new(); + static ISO_DATE_RE: OnceLock> = OnceLock::new(); + static DURATION_RE: OnceLock> = OnceLock::new(); + static PATH_RE: OnceLock> = OnceLock::new(); + static ELAPSED_RE: OnceLock> = OnceLock::new(); + static TOKENS_RE: OnceLock> = OnceLock::new(); + + let Some(uuid_re) = UUID_RE + .get_or_init(|| { + compile_regex( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + ) + }) + .as_ref() + else { + return s.to_string(); + }; + let Some(session_id_re) = SESSION_ID_RE + .get_or_init(|| compile_regex(r"session_[0-9a-zA-Z_]+")) + .as_ref() + else { + return s.to_string(); + }; + let Some(timestamp_re) = TIMESTAMP_RE + .get_or_init(|| compile_regex(r"\d{10,13}")) + .as_ref() + else { + return s.to_string(); + }; + let Some(iso_date_re) = ISO_DATE_RE + .get_or_init(|| compile_regex(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")) + .as_ref() + else { + return s.to_string(); + }; + let Some(duration_re) = DURATION_RE + .get_or_init(|| compile_regex(r"\d+(\.\d+)?s")) + .as_ref() + else { + return s.to_string(); + }; + let Some(path_re) = PATH_RE + .get_or_init(|| compile_regex(r"/(?:home|Users)/[^/\s]+")) + .as_ref() + else { + return s.to_string(); + }; + let Some(elapsed_re) = ELAPSED_RE + .get_or_init(|| compile_regex(r"\d+m?\d*s")) + .as_ref() + else { + return s.to_string(); + }; + let Some(tokens_re) = TOKENS_RE + .get_or_init(|| compile_regex(r"\d+[kK]? tokens?")) + .as_ref() + else { + return s.to_string(); + }; + + let mut result = s.to_string(); + + // Replace in order of specificity (most specific first) + result = uuid_re.replace_all(&result, "").to_string(); + result = session_id_re + .replace_all(&result, "") + .to_string(); + result = iso_date_re.replace_all(&result, "").to_string(); + result = elapsed_re.replace_all(&result, "").to_string(); + result = tokens_re.replace_all(&result, "").to_string(); + result = duration_re.replace_all(&result, "").to_string(); + result = path_re.replace_all(&result, "").to_string(); + + // Only replace long timestamps that aren't part of other patterns + if result.len() < 20 { + result = timestamp_re.replace_all(&result, "").to_string(); + } + + result +} + +/// Compare two frames for semantic equality (ignoring volatile fields). +pub fn frames_equal_normalized(a: &FrameCapture, b: &FrameCapture) -> bool { + let norm_a = normalize_frame(a); + let norm_b = normalize_frame(b); + norm_a == norm_b +} + +fn write_frame(file: &mut File, frame: &FrameCapture) -> std::io::Result<()> { + writeln!(file, "--- Frame {} ---", frame.frame_id)?; + writeln!(file, "Time: {:?}", frame.timestamp)?; + writeln!( + file, + "Terminal: {}x{}", + frame.terminal_size.0, frame.terminal_size.1 + )?; + + // State + writeln!(file, "State:")?; + writeln!(file, " is_processing: {}", frame.state.is_processing)?; + writeln!(file, " input_len: {}", frame.state.input_len)?; + writeln!(file, " input_preview: {:?}", frame.state.input_preview)?; + writeln!(file, " cursor_pos: {}", frame.state.cursor_pos)?; + writeln!(file, " scroll_offset: {}", frame.state.scroll_offset)?; + writeln!(file, " queued_count: {}", frame.state.queued_count)?; + writeln!(file, " message_count: {}", frame.state.message_count)?; + writeln!( + file, + " streaming_text_len: {}", + frame.state.streaming_text_len + )?; + writeln!(file, " has_suggestions: {}", frame.state.has_suggestions)?; + writeln!(file, " status: {}", frame.state.status)?; + + // Layout + writeln!(file, "Layout:")?; + writeln!(file, " use_packed: {}", frame.layout.use_packed)?; + writeln!( + file, + " estimated_content_height: {}", + frame.layout.estimated_content_height + )?; + if let Some(r) = frame.layout.messages_area { + writeln!( + file, + " messages_area: ({}, {}) {}x{}", + r.x, r.y, r.width, r.height + )?; + } + if let Some(r) = frame.layout.status_area { + writeln!( + file, + " status_area: ({}, {}) {}x{}", + r.x, r.y, r.width, r.height + )?; + } + if let Some(r) = frame.layout.queued_area { + writeln!( + file, + " queued_area: ({}, {}) {}x{}", + r.x, r.y, r.width, r.height + )?; + } + if let Some(r) = frame.layout.input_area { + writeln!( + file, + " input_area: ({}, {}) {}x{}", + r.x, r.y, r.width, r.height + )?; + } + writeln!( + file, + " input_lines: {} raw, {} wrapped", + frame.layout.input_lines_raw, frame.layout.input_lines_wrapped + )?; + if let Some(margins) = &frame.layout.margins { + writeln!( + file, + " margins: centered={} left_rows={} right_rows={}", + margins.centered, + margins.left_widths.len(), + margins.right_widths.len() + )?; + } + if !frame.layout.widget_placements.is_empty() { + writeln!(file, " widget_placements:")?; + for placement in &frame.layout.widget_placements { + let r = placement.rect; + writeln!( + file, + " {} ({}) at ({}, {}) {}x{}", + placement.kind, placement.side, r.x, r.y, r.width, r.height + )?; + } + } + + // Rendered text + writeln!(file, "Rendered:")?; + writeln!(file, " status_line: {:?}", frame.rendered_text.status_line)?; + if let Some(hint) = &frame.rendered_text.input_hint { + writeln!(file, " input_hint: {:?}", hint)?; + } + writeln!(file, " input_area: {:?}", frame.rendered_text.input_area)?; + if !frame.rendered_text.queued_messages.is_empty() { + writeln!(file, " queued_messages:")?; + for (i, msg) in frame.rendered_text.queued_messages.iter().enumerate() { + writeln!(file, " [{}]: {:?}", i, msg)?; + } + } + if !frame.rendered_text.recent_messages.is_empty() { + writeln!(file, " recent_messages:")?; + for msg in &frame.rendered_text.recent_messages { + writeln!( + file, + " [{}] ({} chars): {:?}", + msg.role, msg.content_len, msg.content_preview + )?; + } + } + if !frame.rendered_text.streaming_text_preview.is_empty() { + writeln!( + file, + " streaming_text: {:?}", + frame.rendered_text.streaming_text_preview + )?; + } + if !frame.image_regions.is_empty() { + writeln!(file, " image_regions:")?; + for region in &frame.image_regions { + writeln!( + file, + " {} @{} (h={})", + region.hash, region.abs_line_idx, region.height + )?; + } + } + + // Render timing + if let Some(timing) = &frame.render_timing { + writeln!( + file, + "Timing: prepare={:.2}ms draw={:.2}ms total={:.2}ms messages={:?} widgets={:?}", + timing.prepare_ms, + timing.draw_ms, + timing.total_ms, + timing.messages_ms, + timing.widgets_ms + )?; + } + + // Info widget summary + if let Some(info) = &frame.info_widgets { + writeln!(file, "InfoWidgets:")?; + writeln!( + file, + " todos: {}/{} done, context_chars: {:?}, model: {:?}", + info.summary.todos_done, + info.summary.todos_total, + info.summary.context_total_chars, + info.summary.model + )?; + writeln!( + file, + " session_count: {:?}, client_count: {:?}, swarm_members: {:?}", + info.summary.session_count, info.summary.client_count, info.summary.swarm_member_count + )?; + } + + if !frame.render_order.is_empty() { + writeln!(file, "Render order:")?; + for step in &frame.render_order { + writeln!(file, " - {}", step)?; + } + } + + if let Some(mermaid) = &frame.mermaid { + writeln!(file, "Mermaid: {}", mermaid)?; + } + if let Some(side_panel) = &frame.side_panel { + writeln!(file, "Side panel: {}", side_panel)?; + } + if let Some(markdown) = &frame.markdown { + writeln!(file, "Markdown: {}", markdown)?; + } + if let Some(theme) = &frame.theme { + writeln!(file, "Theme: {}", theme)?; + } + + // Anomalies + if !frame.anomalies.is_empty() { + writeln!(file, "ANOMALIES:")?; + for anomaly in &frame.anomalies { + writeln!(file, " ⚠ {}", anomaly)?; + } + } + + writeln!(file)?; + Ok(()) +} + +/// Builder for constructing frame captures during rendering +#[derive(Default)] +pub struct FrameCaptureBuilder { + pub layout: LayoutCapture, + pub state: StateSnapshot, + pub rendered_text: RenderedText, + pub image_regions: Vec, + pub anomalies: Vec, + pub render_timing: Option, + pub info_widgets: Option, + pub render_order: Vec, + pub mermaid: Option, + pub side_panel: Option, + pub markdown: Option, + pub theme: Option, + terminal_size: (u16, u16), +} + +impl FrameCaptureBuilder { + pub fn new(width: u16, height: u16) -> Self { + Self { + terminal_size: (width, height), + ..Default::default() + } + } + + /// Record an anomaly detected during rendering + pub fn anomaly(&mut self, msg: impl Into) { + self.anomalies.push(msg.into()); + } + + /// Check a condition and record anomaly if false + pub fn check(&mut self, condition: bool, msg: impl Into) { + if !condition { + self.anomalies.push(msg.into()); + } + } + + /// Build the final frame capture + pub fn build(self) -> FrameCapture { + FrameCapture { + frame_id: 0, // Will be set by buffer + timestamp: std::time::SystemTime::now(), + terminal_size: self.terminal_size, + layout: self.layout, + state: self.state, + anomalies: self.anomalies, + rendered_text: self.rendered_text, + image_regions: self.image_regions, + render_timing: self.render_timing, + info_widgets: self.info_widgets, + render_order: self.render_order, + mermaid: self.mermaid, + side_panel: self.side_panel, + markdown: self.markdown, + theme: self.theme, + } + } +} + +/// Check for the specific alternate-send hint anomaly. +pub fn check_shift_enter_anomaly( + builder: &mut FrameCaptureBuilder, + is_processing: bool, + input_text: &str, + hint_shown: bool, +) { + // The hint should ONLY show when processing AND input is non-empty + let should_show = is_processing && !input_text.is_empty(); + + if hint_shown != should_show { + builder.anomaly(format!( + "alternate-send hint mismatch: shown={}, should_show={} (is_processing={}, input_len={})", + hint_shown, + should_show, + is_processing, + input_text.len() + )); + } + + // Also check if the hint text appears in the input itself (the bug!) + if input_text.to_lowercase().contains("shift") && input_text.to_lowercase().contains("enter") { + builder.anomaly(format!( + "INPUT CONTAINS 'shift'+'enter' - possible hint leak: {:?}", + input_text + )); + } +} diff --git a/crates/jcode-tui/Cargo.toml b/crates/jcode-tui/Cargo.toml index 8c8a4fdb0..d0950767d 100644 --- a/crates/jcode-tui/Cargo.toml +++ b/crates/jcode-tui/Cargo.toml @@ -75,6 +75,7 @@ jcode-tui-core = { path = "../jcode-tui-core" } jcode-tui-mermaid = { path = "../jcode-tui-mermaid" } jcode-tui-account-picker = { path = "../jcode-tui-account-picker" } jcode-tui-render = { path = "../jcode-tui-render" } +jcode-tui-visual-debug = { path = "../jcode-tui-visual-debug" } jcode-tui-session-picker = { path = "../jcode-tui-session-picker", features = ["serde"] } jcode-tui-style = { path = "../jcode-tui-style" } jcode-tui-tool-display = { path = "../jcode-tui-tool-display" } diff --git a/crates/jcode-tui/src/tui/visual_debug.rs b/crates/jcode-tui/src/tui/visual_debug.rs index 60c4ade0e..e602420cb 100644 --- a/crates/jcode-tui/src/tui/visual_debug.rs +++ b/crates/jcode-tui/src/tui/visual_debug.rs @@ -1,855 +1,8 @@ -//! Visual Debug Infrastructure -//! -//! Captures TUI frame state for autonomous debugging by AI agents. -//! When enabled, writes detailed render information to a debug file -//! that can be read to understand visual bugs without seeing the terminal. - -use std::collections::VecDeque; -use std::fs::{self, File}; -use std::io::Write; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; - -use ratatui::layout::Rect; -use serde::Serialize; -use serde_json::Value; - -/// Global flag to enable visual debugging (set via /debug-visual command) -static VISUAL_DEBUG_ENABLED: AtomicBool = AtomicBool::new(false); -/// Global flag to enable overlay drawing -static VISUAL_DEBUG_OVERLAY: AtomicBool = AtomicBool::new(false); - -/// Maximum number of frames to keep in the ring buffer -const MAX_FRAMES: usize = 100; - -/// Global frame buffer -static FRAME_BUFFER: OnceLock> = OnceLock::new(); - -fn get_frame_buffer() -> &'static Mutex { - FRAME_BUFFER.get_or_init(|| Mutex::new(FrameBuffer::new())) -} - -/// A captured frame with all render context -#[derive(Debug, Clone, Serialize)] -pub struct FrameCapture { - /// Frame number (monotonically increasing) - pub frame_id: u64, - /// Timestamp when frame was rendered - pub timestamp: std::time::SystemTime, - /// Terminal dimensions - pub terminal_size: (u16, u16), - /// Layout areas computed for this frame - pub layout: LayoutCapture, - /// State snapshot at render time - pub state: StateSnapshot, - /// Any anomalies detected during rendering - pub anomalies: Vec, - /// The actual text content rendered to each area (stripped of ANSI) - pub rendered_text: RenderedText, - /// Mermaid image regions detected in wrapped content - pub image_regions: Vec, - /// Render timing information (milliseconds) - pub render_timing: Option, - /// Info widget placements and summary data - pub info_widgets: Option, - /// Render order for major phases - pub render_order: Vec, - /// Mermaid debug stats snapshot (if available) - pub mermaid: Option, - /// Side-panel debug snapshot, including live Mermaid utilization when available - pub side_panel: Option, - /// Markdown debug stats snapshot (if available) - pub markdown: Option, - /// Theme/palette snapshot (if available) - pub theme: Option, -} - -/// Captured layout computation -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct LayoutCapture { - /// Whether packed layout was used (vs scrolling) - pub use_packed: bool, - /// Estimated content height - pub estimated_content_height: usize, - /// Messages area - pub messages_area: Option, - /// Diagram area (pinned diagram pane) - pub diagram_area: Option, - /// Status line area - pub status_area: Option, - /// Queued messages area - pub queued_area: Option, - /// Input area - pub input_area: Option, - /// Input line count (before wrapping) - pub input_lines_raw: usize, - /// Input line count (after wrapping) - pub input_lines_wrapped: usize, - /// Margin widths for info widgets (per visible row) - pub margins: Option, - /// Info widget placements - pub widget_placements: Vec, -} - -/// Rect capture (serializable) -#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)] -pub struct RectCapture { - pub x: u16, - pub y: u16, - pub width: u16, - pub height: u16, -} - -/// Margin widths captured for debug -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct MarginsCapture { - pub left_widths: Vec, - pub right_widths: Vec, - pub centered: bool, -} - -/// Info widget placement capture -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct WidgetPlacementCapture { - pub kind: String, - pub side: String, - pub rect: RectCapture, -} - -/// Render timing capture (milliseconds) -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct RenderTimingCapture { - pub prepare_ms: f32, - pub draw_ms: f32, - pub total_ms: f32, - pub messages_ms: Option, - pub widgets_ms: Option, -} - -/// Info widget summary capture -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct InfoWidgetSummary { - pub todos_total: usize, - pub todos_done: usize, - pub context_total_chars: Option, - pub context_limit: Option, - pub queue_mode: Option, - pub model: Option, - pub reasoning_effort: Option, - pub session_count: Option, - pub client_count: Option, - pub memory_total: Option, - pub memory_project: Option, - pub memory_global: Option, - pub memory_activity: Option, - pub swarm_session_count: Option, - pub swarm_member_count: Option, - pub swarm_subagent_status: Option, - pub background_running: Option, - pub background_tasks: Option, - pub usage_available: Option, - pub usage_provider: Option, - pub tokens_per_second: Option, - pub auth_method: Option, - pub upstream_provider: Option, -} - -/// Info widget capture (summary + placements) -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct InfoWidgetCapture { - pub summary: InfoWidgetSummary, - pub placements: Vec, -} - -impl From for RectCapture { - fn from(r: Rect) -> Self { - Self { - x: r.x, - y: r.y, - width: r.width, - height: r.height, - } - } -} - -/// State snapshot at render time -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct StateSnapshot { - pub is_processing: bool, - pub input_len: usize, - pub input_preview: String, - pub cursor_pos: usize, - pub scroll_offset: usize, - pub queued_count: usize, - pub message_count: usize, - pub streaming_text_len: usize, - pub has_suggestions: bool, - pub status: String, - pub diagram_mode: Option, - pub diagram_focus: bool, - pub diagram_index: usize, - pub diagram_count: usize, - pub diagram_scroll_x: i32, - pub diagram_scroll_y: i32, - pub diagram_pane_ratio: u8, - pub diagram_pane_enabled: bool, - pub diagram_pane_position: Option, - pub diagram_zoom: u8, -} - -/// Actual rendered text content -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct RenderedText { - /// Status line text (spinner, tokens, elapsed, etc.) - pub status_line: String, - /// Input area text (what the user is typing) - pub input_area: String, - /// Hint text shown above input (if any) - pub input_hint: Option, - /// Queued messages (messages waiting to be sent) - pub queued_messages: Vec, - /// Recent messages displayed (last few for context) - pub recent_messages: Vec, - /// Streaming text (if currently streaming) - pub streaming_text_preview: String, -} - -/// Mermaid image region capture -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct ImageRegionCapture { - pub hash: String, - pub abs_line_idx: usize, - pub height: u16, -} - -/// Captured message for debugging -#[derive(Debug, Clone, Default, PartialEq, Serialize)] -pub struct MessageCapture { - pub role: String, - pub content_preview: String, - pub content_len: usize, -} - -/// Ring buffer of recent frames -struct FrameBuffer { - frames: VecDeque, - next_frame_id: u64, -} - -#[derive(Debug, Clone, Default, Serialize)] -pub struct VisualDebugMemoryProfile { - pub enabled: bool, - pub overlay_enabled: bool, - pub frames_in_buffer: usize, - pub max_frames: usize, - pub total_frames_captured: u64, - pub anomalous_frames_in_buffer: usize, - pub frame_json_estimate_bytes: usize, -} - -impl FrameBuffer { - fn new() -> Self { - Self { - frames: VecDeque::with_capacity(MAX_FRAMES), - next_frame_id: 0, - } - } - - fn push(&mut self, mut frame: FrameCapture) { - frame.frame_id = self.next_frame_id; - self.next_frame_id += 1; - - if self.frames.len() >= MAX_FRAMES { - self.frames.pop_front(); - } - self.frames.push_back(frame); - } - - fn recent(&self, count: usize) -> Vec<&FrameCapture> { - self.frames.iter().rev().take(count).collect() - } - - fn frames_with_anomalies(&self) -> Vec<&FrameCapture> { - self.frames - .iter() - .filter(|f| !f.anomalies.is_empty()) - .collect() - } -} - -/// Enable visual debugging -pub fn enable() { - VISUAL_DEBUG_ENABLED.store(true, Ordering::SeqCst); - crate::logging::info("Visual debugging enabled"); -} - -/// Disable visual debugging -pub fn disable() { - VISUAL_DEBUG_ENABLED.store(false, Ordering::SeqCst); -} - -/// Enable or disable overlay drawing -pub fn set_overlay(enabled: bool) { - VISUAL_DEBUG_OVERLAY.store(enabled, Ordering::SeqCst); -} - -/// Check if overlay drawing is enabled -pub fn overlay_enabled() -> bool { - VISUAL_DEBUG_OVERLAY.load(Ordering::SeqCst) -} - -/// Check if visual debugging is enabled -pub fn is_enabled() -> bool { - VISUAL_DEBUG_ENABLED.load(Ordering::SeqCst) -} - -/// Record a frame capture (skips if identical to previous frame) -pub fn record_frame(frame: FrameCapture) { - if !is_enabled() { - return; - } - - let mut buffer = get_frame_buffer() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - // Skip duplicate frames - only capture when something changes - // Always capture frames with anomalies - if let Some(last) = buffer.frames.back() { - let dominated = frame.state == last.state - && frame.rendered_text == last.rendered_text - && frame.layout == last.layout - && frame.info_widgets == last.info_widgets - && frame.side_panel == last.side_panel - && frame.anomalies.is_empty(); - if dominated { - return; - } - } - - buffer.push(frame); -} - -/// Get the debug output path -fn debug_path() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("jcode") - .join("visual-debug.txt") -} - -/// Dump recent frames to the debug file -pub fn dump_to_file() -> std::io::Result { - let path = debug_path(); - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - let buffer = get_frame_buffer() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let mut file = File::create(&path)?; - - writeln!(file, "=== JCODE VISUAL DEBUG DUMP ===")?; - writeln!(file, "Generated: {:?}", std::time::SystemTime::now())?; - writeln!(file, "Total frames captured: {}", buffer.next_frame_id)?; - writeln!(file, "Frames in buffer: {}", buffer.frames.len())?; - writeln!(file)?; - - // First, show frames with anomalies - let anomaly_frames = buffer.frames_with_anomalies(); - if !anomaly_frames.is_empty() { - writeln!( - file, - "=== FRAMES WITH ANOMALIES ({}) ===", - anomaly_frames.len() - )?; - for frame in anomaly_frames { - write_frame(&mut file, frame)?; - } - writeln!(file)?; - } - - // Then show recent frames - writeln!(file, "=== RECENT FRAMES (last 20) ===")?; - for frame in buffer.recent(20) { - write_frame(&mut file, frame)?; - } - - Ok(path) -} - -/// Return the most recent frame capture. -pub fn latest_frame() -> Option { - let buffer = get_frame_buffer().lock().ok()?; - buffer.frames.back().cloned() -} - -/// Return the most recent frame as a JSON string. -pub fn latest_frame_json() -> Option { - let frame = latest_frame()?; - serde_json::to_string_pretty(&frame).ok() -} - -/// Return the most recent frame as a normalized JSON string (for stable diffs). -/// Strips timestamps, UUIDs, session IDs, and other non-deterministic values. -pub fn latest_frame_json_normalized() -> Option { - let frame = latest_frame()?; - let normalized = normalize_frame(&frame); - serde_json::to_string_pretty(&normalized).ok() -} - -pub fn debug_memory_profile() -> VisualDebugMemoryProfile { - let Ok(buffer) = get_frame_buffer().lock() else { - return VisualDebugMemoryProfile { - enabled: is_enabled(), - overlay_enabled: overlay_enabled(), - max_frames: MAX_FRAMES, - ..VisualDebugMemoryProfile::default() - }; - }; - - VisualDebugMemoryProfile { - enabled: is_enabled(), - overlay_enabled: overlay_enabled(), - frames_in_buffer: buffer.frames.len(), - max_frames: MAX_FRAMES, - total_frames_captured: buffer.next_frame_id, - anomalous_frames_in_buffer: buffer - .frames - .iter() - .filter(|f| !f.anomalies.is_empty()) - .count(), - frame_json_estimate_bytes: buffer - .frames - .iter() - .map(crate::process_memory::estimate_json_bytes) - .sum(), - } -} - -/// Normalize a frame capture for stable comparisons. -/// Replaces timestamps, UUIDs, session IDs, and other volatile values with placeholders. -pub fn normalize_frame(frame: &FrameCapture) -> serde_json::Value { - let json = serde_json::to_value(frame).unwrap_or(serde_json::Value::Null); - normalize_json_value(json) -} - -/// Recursively normalize JSON values, replacing volatile content. -fn normalize_json_value(value: serde_json::Value) -> serde_json::Value { - use serde_json::Value; - - match value { - Value::String(s) => Value::String(normalize_string(&s)), - Value::Array(arr) => Value::Array(arr.into_iter().map(normalize_json_value).collect()), - Value::Object(map) => { - let mut new_map = serde_json::Map::new(); - for (k, v) in map { - // Skip timestamp fields entirely or normalize them - if k == "timestamp" || k == "created_at" || k == "updated_at" { - new_map.insert(k, Value::String("".to_string())); - } else if k == "frame_id" { - // Keep frame_id but note it's sequential - new_map.insert(k, v); - } else { - new_map.insert(k, normalize_json_value(v)); - } - } - Value::Object(new_map) - } - other => other, - } -} - -/// Normalize a string by replacing volatile patterns with placeholders. -fn normalize_string(s: &str) -> String { - use regex::Regex; - use std::sync::OnceLock; - - fn compile_regex(pattern: &str) -> Option { - match Regex::new(pattern) { - Ok(regex) => Some(regex), - Err(err) => { - crate::logging::warn(&format!( - "visual_debug: failed to compile normalization regex: {}", - err - )); - None - } - } - } - - // Cached regex patterns for performance - static UUID_RE: OnceLock> = OnceLock::new(); - static SESSION_ID_RE: OnceLock> = OnceLock::new(); - static TIMESTAMP_RE: OnceLock> = OnceLock::new(); - static ISO_DATE_RE: OnceLock> = OnceLock::new(); - static DURATION_RE: OnceLock> = OnceLock::new(); - static PATH_RE: OnceLock> = OnceLock::new(); - static ELAPSED_RE: OnceLock> = OnceLock::new(); - static TOKENS_RE: OnceLock> = OnceLock::new(); - - let Some(uuid_re) = UUID_RE - .get_or_init(|| { - compile_regex( - r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", - ) - }) - .as_ref() - else { - return s.to_string(); - }; - let Some(session_id_re) = SESSION_ID_RE - .get_or_init(|| compile_regex(r"session_[0-9a-zA-Z_]+")) - .as_ref() - else { - return s.to_string(); - }; - let Some(timestamp_re) = TIMESTAMP_RE - .get_or_init(|| compile_regex(r"\d{10,13}")) - .as_ref() - else { - return s.to_string(); - }; - let Some(iso_date_re) = ISO_DATE_RE - .get_or_init(|| compile_regex(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")) - .as_ref() - else { - return s.to_string(); - }; - let Some(duration_re) = DURATION_RE - .get_or_init(|| compile_regex(r"\d+(\.\d+)?s")) - .as_ref() - else { - return s.to_string(); - }; - let Some(path_re) = PATH_RE - .get_or_init(|| compile_regex(r"/(?:home|Users)/[^/\s]+")) - .as_ref() - else { - return s.to_string(); - }; - let Some(elapsed_re) = ELAPSED_RE - .get_or_init(|| compile_regex(r"\d+m?\d*s")) - .as_ref() - else { - return s.to_string(); - }; - let Some(tokens_re) = TOKENS_RE - .get_or_init(|| compile_regex(r"\d+[kK]? tokens?")) - .as_ref() - else { - return s.to_string(); - }; - - let mut result = s.to_string(); - - // Replace in order of specificity (most specific first) - result = uuid_re.replace_all(&result, "").to_string(); - result = session_id_re - .replace_all(&result, "") - .to_string(); - result = iso_date_re.replace_all(&result, "").to_string(); - result = elapsed_re.replace_all(&result, "").to_string(); - result = tokens_re.replace_all(&result, "").to_string(); - result = duration_re.replace_all(&result, "").to_string(); - result = path_re.replace_all(&result, "").to_string(); - - // Only replace long timestamps that aren't part of other patterns - if result.len() < 20 { - result = timestamp_re.replace_all(&result, "").to_string(); - } - - result -} - -/// Compare two frames for semantic equality (ignoring volatile fields). -pub fn frames_equal_normalized(a: &FrameCapture, b: &FrameCapture) -> bool { - let norm_a = normalize_frame(a); - let norm_b = normalize_frame(b); - norm_a == norm_b -} - -fn write_frame(file: &mut File, frame: &FrameCapture) -> std::io::Result<()> { - writeln!(file, "--- Frame {} ---", frame.frame_id)?; - writeln!(file, "Time: {:?}", frame.timestamp)?; - writeln!( - file, - "Terminal: {}x{}", - frame.terminal_size.0, frame.terminal_size.1 - )?; - - // State - writeln!(file, "State:")?; - writeln!(file, " is_processing: {}", frame.state.is_processing)?; - writeln!(file, " input_len: {}", frame.state.input_len)?; - writeln!(file, " input_preview: {:?}", frame.state.input_preview)?; - writeln!(file, " cursor_pos: {}", frame.state.cursor_pos)?; - writeln!(file, " scroll_offset: {}", frame.state.scroll_offset)?; - writeln!(file, " queued_count: {}", frame.state.queued_count)?; - writeln!(file, " message_count: {}", frame.state.message_count)?; - writeln!( - file, - " streaming_text_len: {}", - frame.state.streaming_text_len - )?; - writeln!(file, " has_suggestions: {}", frame.state.has_suggestions)?; - writeln!(file, " status: {}", frame.state.status)?; - - // Layout - writeln!(file, "Layout:")?; - writeln!(file, " use_packed: {}", frame.layout.use_packed)?; - writeln!( - file, - " estimated_content_height: {}", - frame.layout.estimated_content_height - )?; - if let Some(r) = frame.layout.messages_area { - writeln!( - file, - " messages_area: ({}, {}) {}x{}", - r.x, r.y, r.width, r.height - )?; - } - if let Some(r) = frame.layout.status_area { - writeln!( - file, - " status_area: ({}, {}) {}x{}", - r.x, r.y, r.width, r.height - )?; - } - if let Some(r) = frame.layout.queued_area { - writeln!( - file, - " queued_area: ({}, {}) {}x{}", - r.x, r.y, r.width, r.height - )?; - } - if let Some(r) = frame.layout.input_area { - writeln!( - file, - " input_area: ({}, {}) {}x{}", - r.x, r.y, r.width, r.height - )?; - } - writeln!( - file, - " input_lines: {} raw, {} wrapped", - frame.layout.input_lines_raw, frame.layout.input_lines_wrapped - )?; - if let Some(margins) = &frame.layout.margins { - writeln!( - file, - " margins: centered={} left_rows={} right_rows={}", - margins.centered, - margins.left_widths.len(), - margins.right_widths.len() - )?; - } - if !frame.layout.widget_placements.is_empty() { - writeln!(file, " widget_placements:")?; - for placement in &frame.layout.widget_placements { - let r = placement.rect; - writeln!( - file, - " {} ({}) at ({}, {}) {}x{}", - placement.kind, placement.side, r.x, r.y, r.width, r.height - )?; - } - } - - // Rendered text - writeln!(file, "Rendered:")?; - writeln!(file, " status_line: {:?}", frame.rendered_text.status_line)?; - if let Some(hint) = &frame.rendered_text.input_hint { - writeln!(file, " input_hint: {:?}", hint)?; - } - writeln!(file, " input_area: {:?}", frame.rendered_text.input_area)?; - if !frame.rendered_text.queued_messages.is_empty() { - writeln!(file, " queued_messages:")?; - for (i, msg) in frame.rendered_text.queued_messages.iter().enumerate() { - writeln!(file, " [{}]: {:?}", i, msg)?; - } - } - if !frame.rendered_text.recent_messages.is_empty() { - writeln!(file, " recent_messages:")?; - for msg in &frame.rendered_text.recent_messages { - writeln!( - file, - " [{}] ({} chars): {:?}", - msg.role, msg.content_len, msg.content_preview - )?; - } - } - if !frame.rendered_text.streaming_text_preview.is_empty() { - writeln!( - file, - " streaming_text: {:?}", - frame.rendered_text.streaming_text_preview - )?; - } - if !frame.image_regions.is_empty() { - writeln!(file, " image_regions:")?; - for region in &frame.image_regions { - writeln!( - file, - " {} @{} (h={})", - region.hash, region.abs_line_idx, region.height - )?; - } - } - - // Render timing - if let Some(timing) = &frame.render_timing { - writeln!( - file, - "Timing: prepare={:.2}ms draw={:.2}ms total={:.2}ms messages={:?} widgets={:?}", - timing.prepare_ms, - timing.draw_ms, - timing.total_ms, - timing.messages_ms, - timing.widgets_ms - )?; - } - - // Info widget summary - if let Some(info) = &frame.info_widgets { - writeln!(file, "InfoWidgets:")?; - writeln!( - file, - " todos: {}/{} done, context_chars: {:?}, model: {:?}", - info.summary.todos_done, - info.summary.todos_total, - info.summary.context_total_chars, - info.summary.model - )?; - writeln!( - file, - " session_count: {:?}, client_count: {:?}, swarm_members: {:?}", - info.summary.session_count, info.summary.client_count, info.summary.swarm_member_count - )?; - } - - if !frame.render_order.is_empty() { - writeln!(file, "Render order:")?; - for step in &frame.render_order { - writeln!(file, " - {}", step)?; - } - } - - if let Some(mermaid) = &frame.mermaid { - writeln!(file, "Mermaid: {}", mermaid)?; - } - if let Some(side_panel) = &frame.side_panel { - writeln!(file, "Side panel: {}", side_panel)?; - } - if let Some(markdown) = &frame.markdown { - writeln!(file, "Markdown: {}", markdown)?; - } - if let Some(theme) = &frame.theme { - writeln!(file, "Theme: {}", theme)?; - } - - // Anomalies - if !frame.anomalies.is_empty() { - writeln!(file, "ANOMALIES:")?; - for anomaly in &frame.anomalies { - writeln!(file, " ⚠ {}", anomaly)?; - } - } - - writeln!(file)?; - Ok(()) -} - -/// Builder for constructing frame captures during rendering -#[derive(Default)] -pub struct FrameCaptureBuilder { - pub layout: LayoutCapture, - pub state: StateSnapshot, - pub rendered_text: RenderedText, - pub image_regions: Vec, - pub anomalies: Vec, - pub render_timing: Option, - pub info_widgets: Option, - pub render_order: Vec, - pub mermaid: Option, - pub side_panel: Option, - pub markdown: Option, - pub theme: Option, - terminal_size: (u16, u16), -} - -impl FrameCaptureBuilder { - pub fn new(width: u16, height: u16) -> Self { - Self { - terminal_size: (width, height), - ..Default::default() - } - } - - /// Record an anomaly detected during rendering - pub fn anomaly(&mut self, msg: impl Into) { - self.anomalies.push(msg.into()); - } - - /// Check a condition and record anomaly if false - pub fn check(&mut self, condition: bool, msg: impl Into) { - if !condition { - self.anomalies.push(msg.into()); - } - } - - /// Build the final frame capture - pub fn build(self) -> FrameCapture { - FrameCapture { - frame_id: 0, // Will be set by buffer - timestamp: std::time::SystemTime::now(), - terminal_size: self.terminal_size, - layout: self.layout, - state: self.state, - anomalies: self.anomalies, - rendered_text: self.rendered_text, - image_regions: self.image_regions, - render_timing: self.render_timing, - info_widgets: self.info_widgets, - render_order: self.render_order, - mermaid: self.mermaid, - side_panel: self.side_panel, - markdown: self.markdown, - theme: self.theme, - } - } -} - -/// Check for the specific alternate-send hint anomaly. -pub fn check_shift_enter_anomaly( - builder: &mut FrameCaptureBuilder, - is_processing: bool, - input_text: &str, - hint_shown: bool, -) { - // The hint should ONLY show when processing AND input is non-empty - let should_show = is_processing && !input_text.is_empty(); - - if hint_shown != should_show { - builder.anomaly(format!( - "alternate-send hint mismatch: shown={}, should_show={} (is_processing={}, input_len={})", - hint_shown, - should_show, - is_processing, - input_text.len() - )); - } - - // Also check if the hint text appears in the input itself (the bug!) - if input_text.to_lowercase().contains("shift") && input_text.to_lowercase().contains("enter") { - builder.anomaly(format!( - "INPUT CONTAINS 'shift'+'enter' - possible hint leak: {:?}", - input_text - )); - } -} +pub use jcode_tui_visual_debug::{ + FrameCapture, FrameCaptureBuilder, ImageRegionCapture, InfoWidgetCapture, InfoWidgetSummary, + LayoutCapture, MarginsCapture, MessageCapture, RectCapture, RenderTimingCapture, RenderedText, + StateSnapshot, VisualDebugMemoryProfile, WidgetPlacementCapture, check_shift_enter_anomaly, + debug_memory_profile, disable, dump_to_file, enable, frames_equal_normalized, is_enabled, + latest_frame, latest_frame_json, latest_frame_json_normalized, normalize_frame, + overlay_enabled, record_frame, set_overlay, +}; From a6564b718f8423dc9fccb9a1aca9211af3e63b2b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:04:23 -0700 Subject: [PATCH 24/41] refactor(tui): move account picker overlay into leaf crate --- Cargo.lock | 4 + crates/jcode-tui-account-picker/Cargo.toml | 4 + crates/jcode-tui-account-picker/src/lib.rs | 3 + .../jcode-tui-account-picker/src/overlay.rs | 1050 ++++++++++++++++ .../src/overlay_render.rs} | 2 +- crates/jcode-tui/src/tui/account_picker.rs | 1057 +---------------- 6 files changed, 1066 insertions(+), 1054 deletions(-) create mode 100644 crates/jcode-tui-account-picker/src/overlay.rs rename crates/{jcode-tui/src/tui/account_picker_render.rs => jcode-tui-account-picker/src/overlay_render.rs} (99%) diff --git a/Cargo.lock b/Cargo.lock index 12ddbdda7..36fb75ab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4032,7 +4032,11 @@ dependencies = [ name = "jcode-tui-account-picker" version = "0.1.0" dependencies = [ + "anyhow", + "crossterm", + "ratatui", "serde", + "serde_json", ] [[package]] diff --git a/crates/jcode-tui-account-picker/Cargo.toml b/crates/jcode-tui-account-picker/Cargo.toml index 401efbad0..c8d142f74 100644 --- a/crates/jcode-tui-account-picker/Cargo.toml +++ b/crates/jcode-tui-account-picker/Cargo.toml @@ -5,6 +5,10 @@ edition = "2024" publish = false [dependencies] +anyhow = "1" +crossterm = "0.29" +ratatui = "0.30" +serde_json = "1" serde = { version = "1", features = ["derive"], optional = true } [features] diff --git a/crates/jcode-tui-account-picker/src/lib.rs b/crates/jcode-tui-account-picker/src/lib.rs index e291e336c..20133a046 100644 --- a/crates/jcode-tui-account-picker/src/lib.rs +++ b/crates/jcode-tui-account-picker/src/lib.rs @@ -135,3 +135,6 @@ mod tests { assert!(!item_matches_filter(&item, "claude")); } } + +mod overlay; +pub use overlay::{AccountPicker, OverlayAction}; diff --git a/crates/jcode-tui-account-picker/src/overlay.rs b/crates/jcode-tui-account-picker/src/overlay.rs new file mode 100644 index 000000000..e13664e62 --- /dev/null +++ b/crates/jcode-tui-account-picker/src/overlay.rs @@ -0,0 +1,1050 @@ +use anyhow::Result; +use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use std::collections::HashMap; + +use crate::{AccountPickerCommand, AccountPickerItem, AccountPickerSummary, AccountProviderKind}; + +#[path = "overlay_render.rs"] +mod render_support; +use render_support::{ + ActionSection, account_count_summary, account_is_active, action_icon, action_kind_badge, + action_kind_help, action_section, centered_rect, command_preview, compact_item_title, hotkey, + metric_span, provider_header_line, provider_style, truncate_with_ellipsis, +}; + +const PANEL_BG: Color = Color::Rgb(24, 28, 40); +const PANEL_BORDER: Color = Color::Rgb(90, 95, 110); +const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190); +const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); +const SELECTED_BG: Color = Color::Rgb(38, 42, 56); +const MUTED: Color = Color::Rgb(140, 146, 163); +const MUTED_DARK: Color = Color::Rgb(100, 106, 122); +const OVERLAY_PERCENT_X: u16 = 88; +const OVERLAY_PERCENT_Y: u16 = 74; + +#[derive(Debug, Clone)] +pub struct AccountPicker { + title: String, + items: Vec, + filtered: Vec, + selected: usize, + filter: String, + summary: Option, + last_action_list_area: Option, +} + +pub enum OverlayAction { + Continue, + Close, + Execute(AccountPickerCommand), +} + +impl AccountPicker { + pub fn new(title: impl Into, items: Vec) -> Self { + Self::with_summary(title, items, AccountPickerSummary::default()) + } + + pub fn debug_memory_profile(&self) -> serde_json::Value { + let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum(); + let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::(); + let filter_bytes = self.filter.capacity(); + let title_bytes = self.title.capacity(); + let summary_estimate_bytes = self + .summary + .as_ref() + .map(estimate_summary_bytes) + .unwrap_or(0); + let total_estimate_bytes = items_estimate_bytes + + filtered_estimate_bytes + + filter_bytes + + title_bytes + + summary_estimate_bytes; + + serde_json::json!({ + "items_count": self.items.len(), + "filtered_count": self.filtered.len(), + "selected": self.selected, + "title_bytes": title_bytes, + "filter_bytes": filter_bytes, + "summary_estimate_bytes": summary_estimate_bytes, + "items_estimate_bytes": items_estimate_bytes, + "filtered_estimate_bytes": filtered_estimate_bytes, + "total_estimate_bytes": total_estimate_bytes, + }) + } + + pub fn with_summary( + title: impl Into, + items: Vec, + summary: AccountPickerSummary, + ) -> Self { + let mut picker = Self { + title: title.into(), + items, + filtered: Vec::new(), + selected: 0, + filter: String::new(), + summary: Some(summary), + last_action_list_area: None, + }; + picker.apply_filter(); + picker + } + + fn selected_item(&self) -> Option<&AccountPickerItem> { + self.filtered + .get(self.selected) + .and_then(|idx| self.items.get(*idx)) + } + + fn visible_window_start(&self, available_items: usize) -> usize { + self.selected + .saturating_sub(available_items.saturating_sub(1).min(available_items / 2)) + } + + fn visible_index_for_action_row(&self, row: u16, list_height: u16) -> Option { + if self.filtered.is_empty() { + return None; + } + + let available_items = (list_height as usize).max(1); + let start = self.visible_window_start(available_items); + let end = (start + available_items).min(self.filtered.len()); + let mut current_provider: Option<&str> = None; + let mut rendered_row = 0u16; + + for visible_idx in start..end { + let item = &self.items[self.filtered[visible_idx]]; + if current_provider != Some(item.provider_id.as_str()) { + current_provider = Some(item.provider_id.as_str()); + if rendered_row == row { + return None; + } + rendered_row = rendered_row.saturating_add(1); + if rendered_row >= list_height { + return None; + } + } + + if rendered_row == row { + return Some(visible_idx); + } + rendered_row = rendered_row.saturating_add(1); + if rendered_row > row && rendered_row >= list_height { + return None; + } + } + + None + } + + fn apply_filter(&mut self) { + self.filtered = self + .items + .iter() + .enumerate() + .filter_map(|(idx, item)| crate::item_matches_filter(item, &self.filter).then_some(idx)) + .collect(); + let provider_order = self.provider_order(); + self.filtered.sort_by(|left, right| { + let left_item = &self.items[*left]; + let right_item = &self.items[*right]; + + provider_order + .get(&left_item.provider_id) + .cmp(&provider_order.get(&right_item.provider_id)) + .then_with(|| action_section(left_item).cmp(&action_section(right_item))) + .then_with(|| left_item.title.cmp(&right_item.title)) + .then_with(|| left.cmp(right)) + }); + if self.selected >= self.filtered.len() { + self.selected = self.filtered.len().saturating_sub(1); + } + } + + fn provider_order(&self) -> HashMap { + let mut order = HashMap::new(); + let mut next = 0usize; + for item in &self.items { + if order.contains_key(&item.provider_id) { + continue; + } + let rank = if item.provider_id == "defaults" { + usize::MAX / 2 + } else { + let current = next; + next += 1; + current + }; + order.insert(item.provider_id.clone(), rank); + } + order + } + + fn filtered_provider_switch_count(&self, provider_id: &str) -> usize { + self.filtered + .iter() + .filter(|idx| { + let item = &self.items[**idx]; + item.provider_id == provider_id + && matches!(action_section(item), ActionSection::Switch) + }) + .count() + } + + fn filtered_provider_secondary_count(&self, provider_id: &str) -> usize { + self.filtered + .iter() + .filter(|idx| { + let item = &self.items[**idx]; + item.provider_id == provider_id + && !matches!(action_section(item), ActionSection::Switch) + }) + .count() + } + + fn select_prev_provider_group(&mut self) { + let Some(current_idx) = self.filtered.get(self.selected).copied() else { + return; + }; + let current_provider = self.items[current_idx].provider_id.as_str(); + let mut target = None; + + for pos in (0..self.selected).rev() { + let provider_id = self.items[self.filtered[pos]].provider_id.as_str(); + if provider_id != current_provider { + target = Some(pos); + break; + } + } + + let Some(mut pos) = target else { + return; + }; + let provider_id = self.items[self.filtered[pos]].provider_id.clone(); + while pos > 0 && self.items[self.filtered[pos - 1]].provider_id == provider_id { + pos -= 1; + } + self.selected = pos; + } + + fn select_next_provider_group(&mut self) { + let Some(current_idx) = self.filtered.get(self.selected).copied() else { + return; + }; + let current_provider = self.items[current_idx].provider_id.as_str(); + + for pos in (self.selected + 1)..self.filtered.len() { + let provider_id = self.items[self.filtered[pos]].provider_id.as_str(); + if provider_id != current_provider { + self.selected = pos; + break; + } + } + } + + fn provider_overview_line(&self) -> Line<'static> { + let mut seen = Vec::new(); + let mut stats: HashMap = HashMap::new(); + + for item in &self.items { + if matches!(item.provider_id.as_str(), "defaults" | "account-flow") { + continue; + } + if !stats.contains_key(&item.provider_id) { + seen.push(item.provider_id.clone()); + stats.insert( + item.provider_id.clone(), + (item.provider_label.clone(), 0, 0), + ); + } + if let Some((_, accounts, actions)) = stats.get_mut(&item.provider_id) { + if matches!(action_section(item), ActionSection::Switch) { + *accounts += 1; + } else { + *actions += 1; + } + } + } + + let mut spans = vec![Span::styled("Providers ", Style::default().fg(MUTED_DARK))]; + let mut first = true; + for provider_id in seen { + let Some((label, accounts, actions)) = stats.get(&provider_id) else { + continue; + }; + if !first { + spans.push(Span::styled(" | ", Style::default().fg(MUTED_DARK))); + } + first = false; + let summary = if *accounts > 0 { + format!("{} {}", label, account_count_summary(*accounts)) + } else { + format!( + "{} {} control{}", + label, + actions, + if *actions == 1 { "" } else { "s" } + ) + }; + spans.push(Span::styled(summary, provider_style(&provider_id))); + } + if first { + spans.push(Span::styled( + "No providers available", + Style::default().fg(MUTED), + )); + } + Line::from(spans) + } + + pub fn handle_overlay_key( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) -> Result { + match code { + KeyCode::Esc => { + if !self.filter.is_empty() { + self.filter.clear(); + self.apply_filter(); + return Ok(OverlayAction::Continue); + } + return Ok(OverlayAction::Close); + } + KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(OverlayAction::Close); + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(OverlayAction::Close); + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 1).min(max); + } + KeyCode::Left => { + self.select_prev_provider_group(); + } + KeyCode::Right => { + self.select_next_provider_group(); + } + KeyCode::PageUp | KeyCode::Char('K') => { + self.selected = self.selected.saturating_sub(6); + } + KeyCode::PageDown | KeyCode::Char('J') => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 6).min(max); + } + KeyCode::Home | KeyCode::Char('g') => { + self.selected = 0; + } + KeyCode::End | KeyCode::Char('G') => { + self.selected = self.filtered.len().saturating_sub(1); + } + KeyCode::Backspace => { + if self.filter.pop().is_some() { + self.apply_filter(); + } + } + KeyCode::Enter => { + if let Some(item) = self.selected_item() { + return Ok(OverlayAction::Execute(item.command.clone())); + } + return Ok(OverlayAction::Close); + } + KeyCode::Char(c) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.filter.push(c); + self.apply_filter(); + } + _ => {} + } + Ok(OverlayAction::Continue) + } + + pub fn handle_overlay_mouse(&mut self, mouse: MouseEvent) { + let Some(list_inner) = self.last_action_list_area else { + return; + }; + let inside_list = mouse.column >= list_inner.x + && mouse.column < list_inner.x.saturating_add(list_inner.width) + && mouse.row >= list_inner.y + && mouse.row < list_inner.y.saturating_add(list_inner.height); + + match mouse.kind { + MouseEventKind::ScrollUp if inside_list => { + self.selected = self.selected.saturating_sub(1); + } + MouseEventKind::ScrollDown if inside_list => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 1).min(max); + } + MouseEventKind::Down(MouseButton::Left) if inside_list => { + let row = mouse.row.saturating_sub(list_inner.y); + if let Some(visible_idx) = self.visible_index_for_action_row(row, list_inner.height) + { + self.selected = visible_idx; + } + } + _ => {} + } + } + + pub fn render(&mut self, frame: &mut Frame) { + let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area()); + + let block = Block::default() + .title(format!(" {} ", self.title)) + .title_bottom(Line::from(vec![ + hotkey(" Enter "), + Span::styled(" run ", Style::default().fg(MUTED_DARK)), + hotkey(" Up/Down "), + Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), + hotkey(" Click "), + Span::styled(" select ", Style::default().fg(MUTED_DARK)), + hotkey(" type "), + Span::styled(" filter ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(PANEL_BORDER)); + frame.render_widget(block, area); + + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(7), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(inner); + + self.render_header(frame, rows[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(rows[1]); + + self.render_action_list(frame, body[0]); + self.render_detail_pane(frame, body[1]); + + let footer = Paragraph::new(Line::from(vec![ + Span::styled("Focus ", Style::default().fg(MUTED_DARK)), + Span::styled( + "saved accounts stay surfaced here; click actions to focus them, use Left/Right to jump provider groups, or use `/account settings` for the full text view.", + Style::default().fg(MUTED), + ), + ])); + frame.render_widget(footer, rows[2]); + } + + fn render_header(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(Span::styled( + " Overview ", + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(SECTION_BORDER)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = vec![ + Line::from(vec![ + Span::styled("Filter ", Style::default().fg(MUTED_DARK)), + Span::styled( + if self.filter.is_empty() { + "type provider or account name".to_string() + } else { + self.filter.clone() + }, + if self.filter.is_empty() { + Style::default().fg(Color::Gray).italic() + } else { + Style::default().fg(Color::White) + }, + ), + Span::styled( + format!(" - {} results", self.filtered.len()), + Style::default().fg(MUTED_DARK), + ), + ]), + self.provider_overview_line(), + self.summary_line(), + self.defaults_line(), + ]; + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + + fn render_action_list(&mut self, frame: &mut Frame, area: Rect) { + let title = if self.filtered.is_empty() { + " Providers & Quick Actions ".to_string() + } else { + format!( + " Providers & Quick Actions ({}/{}) ", + self.selected + 1, + self.filtered.len() + ) + }; + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(PANEL_BORDER_ACTIVE)); + let list_inner = block.inner(area); + frame.render_widget(block, area); + self.last_action_list_area = Some(list_inner); + + let available_items = (list_inner.height as usize).max(1); + let start = self.visible_window_start(available_items); + let end = (start + available_items).min(self.filtered.len()); + + let mut lines = Vec::new(); + if self.filtered.is_empty() { + lines.push(Line::from(Span::styled( + "No matching account or provider actions.", + Style::default().fg(Color::Gray).italic(), + ))); + lines.push(Line::from(Span::styled( + "Try `openai`, `claude`, an account label, `login`, or `default`.", + Style::default().fg(MUTED), + ))); + } else { + let mut current_provider: Option<&str> = None; + for visible_idx in start..end { + let idx = self.filtered[visible_idx]; + let item = &self.items[idx]; + let selected = visible_idx == self.selected; + + if current_provider != Some(item.provider_id.as_str()) { + current_provider = Some(item.provider_id.as_str()); + lines.push(provider_header_line( + &item.provider_label, + self.filtered_provider_switch_count(&item.provider_id), + self.filtered_provider_secondary_count(&item.provider_id), + &item.provider_id, + )); + } + + let row_style = if selected { + Style::default().bg(SELECTED_BG) + } else { + Style::default() + }; + let (icon, icon_color) = action_icon(item); + let title = compact_item_title(item); + let meta_width = list_inner.width.saturating_sub(16) as usize; + let meta = truncate_with_ellipsis(&item.subtitle, meta_width); + lines.push(Line::from(vec![ + Span::styled( + if selected { "> " } else { " " }, + row_style.fg(Color::White), + ), + Span::styled(format!("{} ", icon), row_style.fg(icon_color).bold()), + Span::styled( + truncate_with_ellipsis(&title, 22), + row_style.fg(Color::White), + ), + Span::styled(" - ", row_style.fg(MUTED_DARK)), + Span::styled(meta, row_style.fg(MUTED)), + ])); + } + } + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), list_inner); + } + + fn render_detail_pane(&self, frame: &mut Frame, area: Rect) { + let title = self + .selected_item() + .map(|item| format!(" {} ", item.provider_label)) + .unwrap_or_else(|| " Details ".to_string()); + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(SECTION_BORDER)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let Some(item) = self.selected_item() else { + frame.render_widget( + Paragraph::new("No action selected").style(Style::default().fg(Color::DarkGray)), + inner, + ); + return; + }; + + let provider_items: Vec<&AccountPickerItem> = self + .items + .iter() + .filter(|candidate| candidate.provider_id == item.provider_id) + .collect(); + let mut account_items: Vec<&AccountPickerItem> = provider_items + .iter() + .copied() + .filter(|candidate| matches!(action_section(candidate), ActionSection::Switch)) + .collect(); + account_items.sort_by(|left, right| { + account_is_active(right) + .cmp(&account_is_active(left)) + .then_with(|| compact_item_title(left).cmp(&compact_item_title(right))) + }); + let mut secondary_items: Vec<&AccountPickerItem> = provider_items + .iter() + .copied() + .filter(|candidate| !matches!(action_section(candidate), ActionSection::Switch)) + .filter(|candidate| candidate.title != item.title) + .collect(); + secondary_items.sort_by(|left, right| { + action_section(left) + .cmp(&action_section(right)) + .then_with(|| compact_item_title(left).cmp(&compact_item_title(right))) + }); + secondary_items.truncate(6); + let (kind_label, kind_color) = action_kind_badge(&item.command); + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Provider ", Style::default().fg(MUTED_DARK)), + Span::styled( + item.provider_label.clone(), + provider_style(&item.provider_id), + ), + ]), + Line::from(vec![ + Span::styled("Saved accounts ", Style::default().fg(MUTED_DARK)), + Span::styled( + account_count_summary(account_items.len()), + Style::default().fg(Color::White).bold(), + ), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "Quick switch", + Style::default().fg(MUTED_DARK).bold(), + )]), + ]; + + if account_items.is_empty() { + lines.push(Line::from(vec![Span::styled( + "No saved accounts for this provider yet.", + Style::default().fg(MUTED), + )])); + } else { + for account in &account_items { + let is_selected = account.title == item.title; + let bullet = if account_is_active(account) { "*" } else { "o" }; + let note = if is_selected { " [selected]" } else { "" }; + lines.push(Line::from(vec![ + Span::styled( + format!("{} ", bullet), + Style::default().fg(if account_is_active(account) { + Color::Rgb(110, 214, 158) + } else { + MUTED_DARK + }), + ), + Span::styled( + compact_item_title(account), + Style::default().fg(Color::White).bold(), + ), + Span::styled( + note.to_string(), + Style::default().fg(Color::Rgb(170, 210, 255)), + ), + ])); + lines.push(Line::from(vec![Span::styled( + format!( + " {}", + truncate_with_ellipsis( + &account.subtitle, + inner.width.saturating_sub(3) as usize, + ) + ), + Style::default().fg(MUTED), + )])); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Selected action", + Style::default().fg(MUTED_DARK).bold(), + )])); + lines.push(Line::from(vec![ + Span::styled(kind_label, Style::default().fg(kind_color).bold()), + Span::styled(" - ", Style::default().fg(MUTED_DARK)), + Span::styled(item.title.clone(), Style::default().fg(Color::White).bold()), + ])); + lines.push(Line::from(vec![Span::styled( + item.subtitle.clone(), + Style::default().fg(MUTED), + )])); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Runs", + Style::default().fg(MUTED_DARK).bold(), + )])); + lines.push(Line::from(vec![Span::styled( + command_preview(&item.command), + Style::default().fg(Color::White), + )])); + lines.push(Line::from(vec![Span::styled( + action_kind_help(&item.command), + Style::default().fg(MUTED), + )])); + + if !secondary_items.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Other controls", + Style::default().fg(MUTED_DARK).bold(), + )])); + for related in secondary_items { + lines.push(Line::from(vec![ + Span::styled("- ", Style::default().fg(MUTED_DARK)), + Span::styled( + compact_item_title(related), + Style::default().fg(Color::White), + ), + ])); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Press Enter to run this action.", + Style::default().fg(Color::Rgb(170, 210, 255)), + )])); + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + + fn summary_line(&self) -> Line<'static> { + if let Some(summary) = &self.summary { + let mut spans = vec![ + metric_span("ready", summary.ready_count, Color::Rgb(110, 214, 158)), + Span::raw(" "), + metric_span( + "attention", + summary.attention_count, + Color::Rgb(255, 192, 120), + ), + Span::raw(" "), + metric_span("setup", summary.setup_count, Color::Rgb(160, 168, 188)), + Span::raw(" "), + metric_span( + "providers", + summary.provider_count, + Color::Rgb(140, 176, 255), + ), + ]; + if summary.named_account_count > 0 { + spans.push(Span::raw(" ")); + spans.push(metric_span( + "accounts", + summary.named_account_count, + Color::Rgb(196, 170, 255), + )); + } + return Line::from(spans); + } + + Line::from(vec![Span::styled( + format!("{} actions available", self.filtered.len()), + Style::default().fg(MUTED), + )]) + } + + fn defaults_line(&self) -> Line<'static> { + let Some(summary) = &self.summary else { + return Line::from(vec![Span::styled( + "Type to narrow actions by provider, account label, or setting.", + Style::default().fg(MUTED), + )]); + }; + + let provider = summary.default_provider.as_deref().unwrap_or("auto"); + let model = summary + .default_model + .as_deref() + .unwrap_or("provider default"); + + Line::from(vec![ + Span::styled("Defaults ", Style::default().fg(MUTED_DARK)), + Span::styled("provider ", Style::default().fg(MUTED_DARK)), + Span::styled(provider.to_string(), Style::default().fg(Color::White)), + Span::styled(" - model ", Style::default().fg(MUTED_DARK)), + Span::styled(model.to_string(), Style::default().fg(Color::White)), + ]) + } +} + +fn estimate_optional_string_bytes(value: &Option) -> usize { + value.as_ref().map(|value| value.capacity()).unwrap_or(0) +} + +fn estimate_command_bytes(command: &AccountPickerCommand) -> usize { + match command { + AccountPickerCommand::SubmitInput(value) => value.capacity(), + AccountPickerCommand::OpenAccountCenter { provider_filter } + | AccountPickerCommand::OpenAddReplaceFlow { provider_filter } => { + estimate_optional_string_bytes(provider_filter) + } + AccountPickerCommand::PromptValue { + prompt, + command_prefix, + empty_value, + status_notice, + } => { + prompt.capacity() + + command_prefix.capacity() + + estimate_optional_string_bytes(empty_value) + + status_notice.capacity() + } + AccountPickerCommand::Switch { label, .. } + | AccountPickerCommand::Login { label, .. } + | AccountPickerCommand::Remove { label, .. } => label.capacity(), + AccountPickerCommand::PromptNew { .. } => 0, + } +} + +fn estimate_item_bytes(item: &AccountPickerItem) -> usize { + item.provider_id.capacity() + + item.provider_label.capacity() + + item.title.capacity() + + item.subtitle.capacity() + + estimate_command_bytes(&item.command) +} + +fn estimate_summary_bytes(summary: &AccountPickerSummary) -> usize { + estimate_optional_string_bytes(&summary.default_provider) + + estimate_optional_string_bytes(&summary.default_model) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph}; + + #[test] + fn test_account_picker_preserves_underlying_background_outside_panels() { + let mut picker = AccountPicker::new( + " Accounts ", + vec![AccountPickerItem::action( + "openai", + "OpenAI", + "Add account", + "Start login flow", + AccountPickerCommand::SubmitInput("/account openai add default".to_string()), + )], + ); + + let backend = TestBackend::new(40, 12); + let mut terminal = Terminal::new(backend).expect("failed to create terminal"); + terminal + .draw(|frame| { + let area = frame.area(); + let fill = vec![Line::from("X".repeat(area.width as usize)); area.height as usize]; + frame.render_widget(Paragraph::new(fill), area); + picker.render(frame); + }) + .expect("draw failed"); + + let overlay = centered_rect( + OVERLAY_PERCENT_X, + OVERLAY_PERCENT_Y, + Rect::new(0, 0, 40, 12), + ); + let probe = &terminal.backend().buffer()[(overlay.x + overlay.width - 3, overlay.y + 2)]; + assert_eq!(probe.symbol(), "X"); + assert_ne!(probe.bg, Color::Rgb(18, 21, 30)); + } + + #[test] + fn test_account_picker_mouse_click_selects_visible_action_after_group_header() { + let mut picker = AccountPicker::new( + " Accounts ", + vec![ + AccountPickerItem::action( + "openai", + "OpenAI", + "Provider settings", + "configured", + AccountPickerCommand::SubmitInput("/account openai settings".to_string()), + ), + AccountPickerItem::action( + "openai", + "OpenAI", + "Login / refresh", + "OAuth", + AccountPickerCommand::SubmitInput("/account openai login".to_string()), + ), + ], + ); + + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("failed to create terminal"); + terminal + .draw(|frame| picker.render(frame)) + .expect("draw failed"); + + let list_area = picker + .last_action_list_area + .expect("render should record action list area"); + + let initially_selected = picker.selected; + picker.handle_overlay_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: list_area.x + 1, + row: list_area.y, + modifiers: KeyModifiers::empty(), + }); + assert_eq!( + picker.selected, initially_selected, + "provider group header rows should not be selectable" + ); + + let expected_first_action = picker.items[picker.filtered[0]].title.clone(); + // Row 0 is the provider group header; row 1 is the first sorted action. + picker.handle_overlay_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: list_area.x + 1, + row: list_area.y + 1, + modifiers: KeyModifiers::empty(), + }); + + assert_eq!( + picker.selected_item().map(|item| item.title.as_str()), + Some(expected_first_action.as_str()) + ); + } + + #[test] + fn test_prompt_value_command_preview_shows_placeholder() { + let preview = command_preview(&AccountPickerCommand::PromptValue { + prompt: "Enter default model".to_string(), + command_prefix: "/account default-model".to_string(), + empty_value: Some("clear".to_string()), + status_notice: "editing".to_string(), + }); + + assert!(preview.contains("/account default-model ")); + assert!(preview.contains("clear")); + } + + #[test] + fn test_account_picker_sorts_switches_before_settings() { + let picker = AccountPicker::new( + " Accounts ", + vec![ + AccountPickerItem::action( + "openai", + "OpenAI", + "Provider settings", + "configured", + AccountPickerCommand::SubmitInput("/account openai settings".to_string()), + ), + AccountPickerItem::action( + "openai", + "OpenAI", + "Switch account `work`", + "user@example.com - valid - active", + AccountPickerCommand::SubmitInput("/account openai switch work".to_string()), + ), + AccountPickerItem::action( + "defaults", + "Global", + "Default provider", + "Current: auto", + AccountPickerCommand::PromptValue { + prompt: "provider".to_string(), + command_prefix: "/account default-provider".to_string(), + empty_value: Some("auto".to_string()), + status_notice: "editing".to_string(), + }, + ), + ], + ); + + let ordered_titles: Vec = picker + .filtered + .iter() + .map(|idx| picker.items[*idx].title.clone()) + .collect(); + + assert_eq!(ordered_titles[0], "Switch account `work`"); + assert_eq!(ordered_titles[1], "Provider settings"); + assert_eq!(ordered_titles[2], "Default provider"); + } + + #[test] + fn test_account_picker_left_right_jump_by_provider_group() { + let mut picker = AccountPicker::new( + " Accounts ", + vec![ + AccountPickerItem::action( + "claude", + "Claude", + "Switch account `work`", + "a@example.com - valid - active", + AccountPickerCommand::SubmitInput("/account claude switch work".to_string()), + ), + AccountPickerItem::action( + "claude", + "Claude", + "Provider settings", + "configured", + AccountPickerCommand::SubmitInput("/account claude settings".to_string()), + ), + AccountPickerItem::action( + "openai", + "OpenAI", + "Switch account `default`", + "b@example.com - valid - active", + AccountPickerCommand::SubmitInput("/account openai switch default".to_string()), + ), + ], + ); + + picker.selected = 1; + let _ = picker.handle_overlay_key(KeyCode::Right, KeyModifiers::empty()); + assert_eq!( + picker.items[picker.filtered[picker.selected]].provider_id, + "openai" + ); + + let _ = picker.handle_overlay_key(KeyCode::Left, KeyModifiers::empty()); + assert_eq!( + picker.items[picker.filtered[picker.selected]].provider_id, + "claude" + ); + assert_eq!(picker.selected, 0); + } +} diff --git a/crates/jcode-tui/src/tui/account_picker_render.rs b/crates/jcode-tui-account-picker/src/overlay_render.rs similarity index 99% rename from crates/jcode-tui/src/tui/account_picker_render.rs rename to crates/jcode-tui-account-picker/src/overlay_render.rs index d7d068905..c1198d37d 100644 --- a/crates/jcode-tui/src/tui/account_picker_render.rs +++ b/crates/jcode-tui-account-picker/src/overlay_render.rs @@ -130,7 +130,7 @@ pub(super) fn account_count_summary(count: usize) -> String { } pub(super) fn action_kind_label(command: &AccountPickerCommand) -> &'static str { - jcode_tui_account_picker::action_kind_label(command) + crate::action_kind_label(command) } pub(super) fn action_kind_badge(command: &AccountPickerCommand) -> (&'static str, Color) { diff --git a/crates/jcode-tui/src/tui/account_picker.rs b/crates/jcode-tui/src/tui/account_picker.rs index 3e9052473..380aafff2 100644 --- a/crates/jcode-tui/src/tui/account_picker.rs +++ b/crates/jcode-tui/src/tui/account_picker.rs @@ -1,861 +1,13 @@ -use anyhow::Result; -use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph, Wrap}, -}; -use std::collections::HashMap; - pub use jcode_tui_account_picker::{ - AccountPickerCommand, AccountPickerItem, AccountPickerSummary, AccountProviderKind, -}; - -#[path = "account_picker_render.rs"] -mod render_support; -use render_support::{ - ActionSection, account_count_summary, account_is_active, action_icon, action_kind_badge, - action_kind_help, action_section, centered_rect, command_preview, compact_item_title, hotkey, - metric_span, provider_header_line, provider_style, truncate_with_ellipsis, + AccountPicker, AccountPickerCommand, AccountPickerItem, AccountPickerSummary, + AccountProviderKind, OverlayAction, }; -const PANEL_BG: Color = Color::Rgb(24, 28, 40); -const PANEL_BORDER: Color = Color::Rgb(90, 95, 110); -const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190); -const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); -const SELECTED_BG: Color = Color::Rgb(38, 42, 56); -const MUTED: Color = Color::Rgb(140, 146, 163); -const MUTED_DARK: Color = Color::Rgb(100, 106, 122); -const OVERLAY_PERCENT_X: u16 = 88; -const OVERLAY_PERCENT_Y: u16 = 74; - -#[derive(Debug, Clone)] -pub struct AccountPicker { - title: String, - items: Vec, - filtered: Vec, - selected: usize, - filter: String, - summary: Option, - last_action_list_area: Option, -} - -pub enum OverlayAction { - Continue, - Close, - Execute(AccountPickerCommand), -} - -impl AccountPicker { - pub fn new(title: impl Into, items: Vec) -> Self { - Self::with_summary(title, items, AccountPickerSummary::default()) - } - - pub fn debug_memory_profile(&self) -> serde_json::Value { - let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum(); - let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::(); - let filter_bytes = self.filter.capacity(); - let title_bytes = self.title.capacity(); - let summary_estimate_bytes = self - .summary - .as_ref() - .map(estimate_summary_bytes) - .unwrap_or(0); - let total_estimate_bytes = items_estimate_bytes - + filtered_estimate_bytes - + filter_bytes - + title_bytes - + summary_estimate_bytes; - - serde_json::json!({ - "items_count": self.items.len(), - "filtered_count": self.filtered.len(), - "selected": self.selected, - "title_bytes": title_bytes, - "filter_bytes": filter_bytes, - "summary_estimate_bytes": summary_estimate_bytes, - "items_estimate_bytes": items_estimate_bytes, - "filtered_estimate_bytes": filtered_estimate_bytes, - "total_estimate_bytes": total_estimate_bytes, - }) - } - - pub fn with_summary( - title: impl Into, - items: Vec, - summary: AccountPickerSummary, - ) -> Self { - let mut picker = Self { - title: title.into(), - items, - filtered: Vec::new(), - selected: 0, - filter: String::new(), - summary: Some(summary), - last_action_list_area: None, - }; - picker.apply_filter(); - picker - } - - fn selected_item(&self) -> Option<&AccountPickerItem> { - self.filtered - .get(self.selected) - .and_then(|idx| self.items.get(*idx)) - } - - fn visible_window_start(&self, available_items: usize) -> usize { - self.selected - .saturating_sub(available_items.saturating_sub(1).min(available_items / 2)) - } - - fn visible_index_for_action_row(&self, row: u16, list_height: u16) -> Option { - if self.filtered.is_empty() { - return None; - } - - let available_items = (list_height as usize).max(1); - let start = self.visible_window_start(available_items); - let end = (start + available_items).min(self.filtered.len()); - let mut current_provider: Option<&str> = None; - let mut rendered_row = 0u16; - - for visible_idx in start..end { - let item = &self.items[self.filtered[visible_idx]]; - if current_provider != Some(item.provider_id.as_str()) { - current_provider = Some(item.provider_id.as_str()); - if rendered_row == row { - return None; - } - rendered_row = rendered_row.saturating_add(1); - if rendered_row >= list_height { - return None; - } - } - - if rendered_row == row { - return Some(visible_idx); - } - rendered_row = rendered_row.saturating_add(1); - if rendered_row > row && rendered_row >= list_height { - return None; - } - } - - None - } - - fn apply_filter(&mut self) { - self.filtered = self - .items - .iter() - .enumerate() - .filter_map(|(idx, item)| { - jcode_tui_account_picker::item_matches_filter(item, &self.filter).then_some(idx) - }) - .collect(); - let provider_order = self.provider_order(); - self.filtered.sort_by(|left, right| { - let left_item = &self.items[*left]; - let right_item = &self.items[*right]; - - provider_order - .get(&left_item.provider_id) - .cmp(&provider_order.get(&right_item.provider_id)) - .then_with(|| action_section(left_item).cmp(&action_section(right_item))) - .then_with(|| left_item.title.cmp(&right_item.title)) - .then_with(|| left.cmp(right)) - }); - if self.selected >= self.filtered.len() { - self.selected = self.filtered.len().saturating_sub(1); - } - } - - fn provider_order(&self) -> HashMap { - let mut order = HashMap::new(); - let mut next = 0usize; - for item in &self.items { - if order.contains_key(&item.provider_id) { - continue; - } - let rank = if item.provider_id == "defaults" { - usize::MAX / 2 - } else { - let current = next; - next += 1; - current - }; - order.insert(item.provider_id.clone(), rank); - } - order - } - - fn filtered_provider_switch_count(&self, provider_id: &str) -> usize { - self.filtered - .iter() - .filter(|idx| { - let item = &self.items[**idx]; - item.provider_id == provider_id - && matches!(action_section(item), ActionSection::Switch) - }) - .count() - } - - fn filtered_provider_secondary_count(&self, provider_id: &str) -> usize { - self.filtered - .iter() - .filter(|idx| { - let item = &self.items[**idx]; - item.provider_id == provider_id - && !matches!(action_section(item), ActionSection::Switch) - }) - .count() - } - - fn select_prev_provider_group(&mut self) { - let Some(current_idx) = self.filtered.get(self.selected).copied() else { - return; - }; - let current_provider = self.items[current_idx].provider_id.as_str(); - let mut target = None; - - for pos in (0..self.selected).rev() { - let provider_id = self.items[self.filtered[pos]].provider_id.as_str(); - if provider_id != current_provider { - target = Some(pos); - break; - } - } - - let Some(mut pos) = target else { - return; - }; - let provider_id = self.items[self.filtered[pos]].provider_id.clone(); - while pos > 0 && self.items[self.filtered[pos - 1]].provider_id == provider_id { - pos -= 1; - } - self.selected = pos; - } - - fn select_next_provider_group(&mut self) { - let Some(current_idx) = self.filtered.get(self.selected).copied() else { - return; - }; - let current_provider = self.items[current_idx].provider_id.as_str(); - - for pos in (self.selected + 1)..self.filtered.len() { - let provider_id = self.items[self.filtered[pos]].provider_id.as_str(); - if provider_id != current_provider { - self.selected = pos; - break; - } - } - } - - fn provider_overview_line(&self) -> Line<'static> { - let mut seen = Vec::new(); - let mut stats: HashMap = HashMap::new(); - - for item in &self.items { - if matches!(item.provider_id.as_str(), "defaults" | "account-flow") { - continue; - } - if !stats.contains_key(&item.provider_id) { - seen.push(item.provider_id.clone()); - stats.insert( - item.provider_id.clone(), - (item.provider_label.clone(), 0, 0), - ); - } - if let Some((_, accounts, actions)) = stats.get_mut(&item.provider_id) { - if matches!(action_section(item), ActionSection::Switch) { - *accounts += 1; - } else { - *actions += 1; - } - } - } - - let mut spans = vec![Span::styled("Providers ", Style::default().fg(MUTED_DARK))]; - let mut first = true; - for provider_id in seen { - let Some((label, accounts, actions)) = stats.get(&provider_id) else { - continue; - }; - if !first { - spans.push(Span::styled(" | ", Style::default().fg(MUTED_DARK))); - } - first = false; - let summary = if *accounts > 0 { - format!("{} {}", label, account_count_summary(*accounts)) - } else { - format!( - "{} {} control{}", - label, - actions, - if *actions == 1 { "" } else { "s" } - ) - }; - spans.push(Span::styled(summary, provider_style(&provider_id))); - } - if first { - spans.push(Span::styled( - "No providers available", - Style::default().fg(MUTED), - )); - } - Line::from(spans) - } - - pub fn handle_overlay_key( - &mut self, - code: KeyCode, - modifiers: KeyModifiers, - ) -> Result { - match code { - KeyCode::Esc => { - if !self.filter.is_empty() { - self.filter.clear(); - self.apply_filter(); - return Ok(OverlayAction::Continue); - } - return Ok(OverlayAction::Close); - } - KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(OverlayAction::Close); - } - KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(OverlayAction::Close); - } - KeyCode::Up | KeyCode::Char('k') => { - self.selected = self.selected.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 1).min(max); - } - KeyCode::Left => { - self.select_prev_provider_group(); - } - KeyCode::Right => { - self.select_next_provider_group(); - } - KeyCode::PageUp | KeyCode::Char('K') => { - self.selected = self.selected.saturating_sub(6); - } - KeyCode::PageDown | KeyCode::Char('J') => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 6).min(max); - } - KeyCode::Home | KeyCode::Char('g') => { - self.selected = 0; - } - KeyCode::End | KeyCode::Char('G') => { - self.selected = self.filtered.len().saturating_sub(1); - } - KeyCode::Backspace => { - if self.filter.pop().is_some() { - self.apply_filter(); - } - } - KeyCode::Enter => { - if let Some(item) = self.selected_item() { - return Ok(OverlayAction::Execute(item.command.clone())); - } - return Ok(OverlayAction::Close); - } - KeyCode::Char(c) - if !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - self.filter.push(c); - self.apply_filter(); - } - _ => {} - } - Ok(OverlayAction::Continue) - } - - pub fn handle_overlay_mouse(&mut self, mouse: MouseEvent) { - let Some(list_inner) = self.last_action_list_area else { - return; - }; - let inside_list = mouse.column >= list_inner.x - && mouse.column < list_inner.x.saturating_add(list_inner.width) - && mouse.row >= list_inner.y - && mouse.row < list_inner.y.saturating_add(list_inner.height); - - match mouse.kind { - MouseEventKind::ScrollUp if inside_list => { - self.selected = self.selected.saturating_sub(1); - } - MouseEventKind::ScrollDown if inside_list => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 1).min(max); - } - MouseEventKind::Down(MouseButton::Left) if inside_list => { - let row = mouse.row.saturating_sub(list_inner.y); - if let Some(visible_idx) = self.visible_index_for_action_row(row, list_inner.height) - { - self.selected = visible_idx; - } - } - _ => {} - } - } - - pub fn render(&mut self, frame: &mut Frame) { - let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area()); - - let block = Block::default() - .title(format!(" {} ", self.title)) - .title_bottom(Line::from(vec![ - hotkey(" Enter "), - Span::styled(" run ", Style::default().fg(MUTED_DARK)), - hotkey(" Up/Down "), - Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), - hotkey(" Click "), - Span::styled(" select ", Style::default().fg(MUTED_DARK)), - hotkey(" type "), - Span::styled(" filter ", Style::default().fg(MUTED_DARK)), - hotkey(" Esc "), - Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)), - ])) - .borders(Borders::ALL) - .border_style(Style::default().fg(PANEL_BORDER)); - frame.render_widget(block, area); - - let inner = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(7), - Constraint::Min(10), - Constraint::Length(2), - ]) - .split(inner); - - self.render_header(frame, rows[0]); - - let body = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) - .split(rows[1]); - - self.render_action_list(frame, body[0]); - self.render_detail_pane(frame, body[1]); - - let footer = Paragraph::new(Line::from(vec![ - Span::styled("Focus ", Style::default().fg(MUTED_DARK)), - Span::styled( - "saved accounts stay surfaced here; click actions to focus them, use Left/Right to jump provider groups, or use `/account settings` for the full text view.", - Style::default().fg(MUTED), - ), - ])); - frame.render_widget(footer, rows[2]); - } - - fn render_header(&self, frame: &mut Frame, area: Rect) { - let block = Block::default() - .title(Span::styled( - " Overview ", - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(SECTION_BORDER)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let lines = vec![ - Line::from(vec![ - Span::styled("Filter ", Style::default().fg(MUTED_DARK)), - Span::styled( - if self.filter.is_empty() { - "type provider or account name".to_string() - } else { - self.filter.clone() - }, - if self.filter.is_empty() { - Style::default().fg(Color::Gray).italic() - } else { - Style::default().fg(Color::White) - }, - ), - Span::styled( - format!(" - {} results", self.filtered.len()), - Style::default().fg(MUTED_DARK), - ), - ]), - self.provider_overview_line(), - self.summary_line(), - self.defaults_line(), - ]; - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); - } - - fn render_action_list(&mut self, frame: &mut Frame, area: Rect) { - let title = if self.filtered.is_empty() { - " Providers & Quick Actions ".to_string() - } else { - format!( - " Providers & Quick Actions ({}/{}) ", - self.selected + 1, - self.filtered.len() - ) - }; - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(PANEL_BORDER_ACTIVE)); - let list_inner = block.inner(area); - frame.render_widget(block, area); - self.last_action_list_area = Some(list_inner); - - let available_items = (list_inner.height as usize).max(1); - let start = self.visible_window_start(available_items); - let end = (start + available_items).min(self.filtered.len()); - - let mut lines = Vec::new(); - if self.filtered.is_empty() { - lines.push(Line::from(Span::styled( - "No matching account or provider actions.", - Style::default().fg(Color::Gray).italic(), - ))); - lines.push(Line::from(Span::styled( - "Try `openai`, `claude`, an account label, `login`, or `default`.", - Style::default().fg(MUTED), - ))); - } else { - let mut current_provider: Option<&str> = None; - for visible_idx in start..end { - let idx = self.filtered[visible_idx]; - let item = &self.items[idx]; - let selected = visible_idx == self.selected; - - if current_provider != Some(item.provider_id.as_str()) { - current_provider = Some(item.provider_id.as_str()); - lines.push(provider_header_line( - &item.provider_label, - self.filtered_provider_switch_count(&item.provider_id), - self.filtered_provider_secondary_count(&item.provider_id), - &item.provider_id, - )); - } - - let row_style = if selected { - Style::default().bg(SELECTED_BG) - } else { - Style::default() - }; - let (icon, icon_color) = action_icon(item); - let title = compact_item_title(item); - let meta_width = list_inner.width.saturating_sub(16) as usize; - let meta = truncate_with_ellipsis(&item.subtitle, meta_width); - lines.push(Line::from(vec![ - Span::styled( - if selected { "> " } else { " " }, - row_style.fg(Color::White), - ), - Span::styled(format!("{} ", icon), row_style.fg(icon_color).bold()), - Span::styled( - truncate_with_ellipsis(&title, 22), - row_style.fg(Color::White), - ), - Span::styled(" - ", row_style.fg(MUTED_DARK)), - Span::styled(meta, row_style.fg(MUTED)), - ])); - } - } - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), list_inner); - } - - fn render_detail_pane(&self, frame: &mut Frame, area: Rect) { - let title = self - .selected_item() - .map(|item| format!(" {} ", item.provider_label)) - .unwrap_or_else(|| " Details ".to_string()); - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(SECTION_BORDER)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let Some(item) = self.selected_item() else { - frame.render_widget( - Paragraph::new("No action selected").style(Style::default().fg(Color::DarkGray)), - inner, - ); - return; - }; - - let provider_items: Vec<&AccountPickerItem> = self - .items - .iter() - .filter(|candidate| candidate.provider_id == item.provider_id) - .collect(); - let mut account_items: Vec<&AccountPickerItem> = provider_items - .iter() - .copied() - .filter(|candidate| matches!(action_section(candidate), ActionSection::Switch)) - .collect(); - account_items.sort_by(|left, right| { - account_is_active(right) - .cmp(&account_is_active(left)) - .then_with(|| compact_item_title(left).cmp(&compact_item_title(right))) - }); - let mut secondary_items: Vec<&AccountPickerItem> = provider_items - .iter() - .copied() - .filter(|candidate| !matches!(action_section(candidate), ActionSection::Switch)) - .filter(|candidate| candidate.title != item.title) - .collect(); - secondary_items.sort_by(|left, right| { - action_section(left) - .cmp(&action_section(right)) - .then_with(|| compact_item_title(left).cmp(&compact_item_title(right))) - }); - secondary_items.truncate(6); - let (kind_label, kind_color) = action_kind_badge(&item.command); - - let mut lines = vec![ - Line::from(vec![ - Span::styled("Provider ", Style::default().fg(MUTED_DARK)), - Span::styled( - item.provider_label.clone(), - provider_style(&item.provider_id), - ), - ]), - Line::from(vec![ - Span::styled("Saved accounts ", Style::default().fg(MUTED_DARK)), - Span::styled( - account_count_summary(account_items.len()), - Style::default().fg(Color::White).bold(), - ), - ]), - Line::from(""), - Line::from(vec![Span::styled( - "Quick switch", - Style::default().fg(MUTED_DARK).bold(), - )]), - ]; - - if account_items.is_empty() { - lines.push(Line::from(vec![Span::styled( - "No saved accounts for this provider yet.", - Style::default().fg(MUTED), - )])); - } else { - for account in &account_items { - let is_selected = account.title == item.title; - let bullet = if account_is_active(account) { "*" } else { "o" }; - let note = if is_selected { " [selected]" } else { "" }; - lines.push(Line::from(vec![ - Span::styled( - format!("{} ", bullet), - Style::default().fg(if account_is_active(account) { - Color::Rgb(110, 214, 158) - } else { - MUTED_DARK - }), - ), - Span::styled( - compact_item_title(account), - Style::default().fg(Color::White).bold(), - ), - Span::styled( - note.to_string(), - Style::default().fg(Color::Rgb(170, 210, 255)), - ), - ])); - lines.push(Line::from(vec![Span::styled( - format!( - " {}", - truncate_with_ellipsis( - &account.subtitle, - inner.width.saturating_sub(3) as usize, - ) - ), - Style::default().fg(MUTED), - )])); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Selected action", - Style::default().fg(MUTED_DARK).bold(), - )])); - lines.push(Line::from(vec![ - Span::styled(kind_label, Style::default().fg(kind_color).bold()), - Span::styled(" - ", Style::default().fg(MUTED_DARK)), - Span::styled(item.title.clone(), Style::default().fg(Color::White).bold()), - ])); - lines.push(Line::from(vec![Span::styled( - item.subtitle.clone(), - Style::default().fg(MUTED), - )])); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Runs", - Style::default().fg(MUTED_DARK).bold(), - )])); - lines.push(Line::from(vec![Span::styled( - command_preview(&item.command), - Style::default().fg(Color::White), - )])); - lines.push(Line::from(vec![Span::styled( - action_kind_help(&item.command), - Style::default().fg(MUTED), - )])); - - if !secondary_items.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Other controls", - Style::default().fg(MUTED_DARK).bold(), - )])); - for related in secondary_items { - lines.push(Line::from(vec![ - Span::styled("- ", Style::default().fg(MUTED_DARK)), - Span::styled( - compact_item_title(related), - Style::default().fg(Color::White), - ), - ])); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Press Enter to run this action.", - Style::default().fg(Color::Rgb(170, 210, 255)), - )])); - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); - } - - fn summary_line(&self) -> Line<'static> { - if let Some(summary) = &self.summary { - let mut spans = vec![ - metric_span("ready", summary.ready_count, Color::Rgb(110, 214, 158)), - Span::raw(" "), - metric_span( - "attention", - summary.attention_count, - Color::Rgb(255, 192, 120), - ), - Span::raw(" "), - metric_span("setup", summary.setup_count, Color::Rgb(160, 168, 188)), - Span::raw(" "), - metric_span( - "providers", - summary.provider_count, - Color::Rgb(140, 176, 255), - ), - ]; - if summary.named_account_count > 0 { - spans.push(Span::raw(" ")); - spans.push(metric_span( - "accounts", - summary.named_account_count, - Color::Rgb(196, 170, 255), - )); - } - return Line::from(spans); - } - - Line::from(vec![Span::styled( - format!("{} actions available", self.filtered.len()), - Style::default().fg(MUTED), - )]) - } - - fn defaults_line(&self) -> Line<'static> { - let Some(summary) = &self.summary else { - return Line::from(vec![Span::styled( - "Type to narrow actions by provider, account label, or setting.", - Style::default().fg(MUTED), - )]); - }; - - let provider = summary.default_provider.as_deref().unwrap_or("auto"); - let model = summary - .default_model - .as_deref() - .unwrap_or("provider default"); - - Line::from(vec![ - Span::styled("Defaults ", Style::default().fg(MUTED_DARK)), - Span::styled("provider ", Style::default().fg(MUTED_DARK)), - Span::styled(provider.to_string(), Style::default().fg(Color::White)), - Span::styled(" - model ", Style::default().fg(MUTED_DARK)), - Span::styled(model.to_string(), Style::default().fg(Color::White)), - ]) - } -} - -fn estimate_optional_string_bytes(value: &Option) -> usize { - value.as_ref().map(|value| value.capacity()).unwrap_or(0) -} - -fn estimate_command_bytes(command: &AccountPickerCommand) -> usize { - match command { - AccountPickerCommand::SubmitInput(value) => value.capacity(), - AccountPickerCommand::OpenAccountCenter { provider_filter } - | AccountPickerCommand::OpenAddReplaceFlow { provider_filter } => { - estimate_optional_string_bytes(provider_filter) - } - AccountPickerCommand::PromptValue { - prompt, - command_prefix, - empty_value, - status_notice, - } => { - prompt.capacity() - + command_prefix.capacity() - + estimate_optional_string_bytes(empty_value) - + status_notice.capacity() - } - AccountPickerCommand::Switch { label, .. } - | AccountPickerCommand::Login { label, .. } - | AccountPickerCommand::Remove { label, .. } => label.capacity(), - AccountPickerCommand::PromptNew { .. } => 0, - } -} - -fn estimate_item_bytes(item: &AccountPickerItem) -> usize { - item.provider_id.capacity() - + item.provider_label.capacity() - + item.title.capacity() - + item.subtitle.capacity() - + estimate_command_bytes(&item.command) -} - -fn estimate_summary_bytes(summary: &AccountPickerSummary) -> usize { - estimate_optional_string_bytes(&summary.default_provider) - + estimate_optional_string_bytes(&summary.default_model) -} - #[cfg(test)] mod tests { use super::*; - use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph}; + use crossterm::event::{KeyCode, KeyModifiers}; + use ratatui::{Terminal, backend::TestBackend}; fn buffer_to_text(buffer: &ratatui::buffer::Buffer) -> String { let area = buffer.area; @@ -877,12 +29,6 @@ mod tests { if tokens.is_empty() { return true; } - - // The account picker renders a list and a detail panel side by side. Long - // action text can wrap in the left column while unrelated right-column - // text occupies the same terminal rows, so the expected prose is not - // always contiguous in the raw buffer. Verify the expected tokens still - // appear in order. let mut start = 0; for token in tokens { let Some(offset) = rendered[start..].find(token) else { @@ -893,201 +39,6 @@ mod tests { true } - #[test] - fn test_account_picker_preserves_underlying_background_outside_panels() { - let mut picker = AccountPicker::new( - " Accounts ", - vec![AccountPickerItem::action( - "openai", - "OpenAI", - "Add account", - "Start login flow", - AccountPickerCommand::SubmitInput("/account openai add default".to_string()), - )], - ); - - let backend = TestBackend::new(40, 12); - let mut terminal = Terminal::new(backend).expect("failed to create terminal"); - terminal - .draw(|frame| { - let area = frame.area(); - let fill = vec![Line::from("X".repeat(area.width as usize)); area.height as usize]; - frame.render_widget(Paragraph::new(fill), area); - picker.render(frame); - }) - .expect("draw failed"); - - let overlay = centered_rect( - OVERLAY_PERCENT_X, - OVERLAY_PERCENT_Y, - Rect::new(0, 0, 40, 12), - ); - let probe = &terminal.backend().buffer()[(overlay.x + overlay.width - 3, overlay.y + 2)]; - assert_eq!(probe.symbol(), "X"); - assert_ne!(probe.bg, Color::Rgb(18, 21, 30)); - } - - #[test] - fn test_account_picker_mouse_click_selects_visible_action_after_group_header() { - let mut picker = AccountPicker::new( - " Accounts ", - vec![ - AccountPickerItem::action( - "openai", - "OpenAI", - "Provider settings", - "configured", - AccountPickerCommand::SubmitInput("/account openai settings".to_string()), - ), - AccountPickerItem::action( - "openai", - "OpenAI", - "Login / refresh", - "OAuth", - AccountPickerCommand::SubmitInput("/account openai login".to_string()), - ), - ], - ); - - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).expect("failed to create terminal"); - terminal - .draw(|frame| picker.render(frame)) - .expect("draw failed"); - - let list_area = picker - .last_action_list_area - .expect("render should record action list area"); - - let initially_selected = picker.selected; - picker.handle_overlay_mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: list_area.x + 1, - row: list_area.y, - modifiers: KeyModifiers::empty(), - }); - assert_eq!( - picker.selected, initially_selected, - "provider group header rows should not be selectable" - ); - - let expected_first_action = picker.items[picker.filtered[0]].title.clone(); - // Row 0 is the provider group header; row 1 is the first sorted action. - picker.handle_overlay_mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: list_area.x + 1, - row: list_area.y + 1, - modifiers: KeyModifiers::empty(), - }); - - assert_eq!( - picker.selected_item().map(|item| item.title.as_str()), - Some(expected_first_action.as_str()) - ); - } - - #[test] - fn test_prompt_value_command_preview_shows_placeholder() { - let preview = command_preview(&AccountPickerCommand::PromptValue { - prompt: "Enter default model".to_string(), - command_prefix: "/account default-model".to_string(), - empty_value: Some("clear".to_string()), - status_notice: "editing".to_string(), - }); - - assert!(preview.contains("/account default-model ")); - assert!(preview.contains("clear")); - } - - #[test] - fn test_account_picker_sorts_switches_before_settings() { - let picker = AccountPicker::new( - " Accounts ", - vec![ - AccountPickerItem::action( - "openai", - "OpenAI", - "Provider settings", - "configured", - AccountPickerCommand::SubmitInput("/account openai settings".to_string()), - ), - AccountPickerItem::action( - "openai", - "OpenAI", - "Switch account `work`", - "user@example.com - valid - active", - AccountPickerCommand::SubmitInput("/account openai switch work".to_string()), - ), - AccountPickerItem::action( - "defaults", - "Global", - "Default provider", - "Current: auto", - AccountPickerCommand::PromptValue { - prompt: "provider".to_string(), - command_prefix: "/account default-provider".to_string(), - empty_value: Some("auto".to_string()), - status_notice: "editing".to_string(), - }, - ), - ], - ); - - let ordered_titles: Vec = picker - .filtered - .iter() - .map(|idx| picker.items[*idx].title.clone()) - .collect(); - - assert_eq!(ordered_titles[0], "Switch account `work`"); - assert_eq!(ordered_titles[1], "Provider settings"); - assert_eq!(ordered_titles[2], "Default provider"); - } - - #[test] - fn test_account_picker_left_right_jump_by_provider_group() { - let mut picker = AccountPicker::new( - " Accounts ", - vec![ - AccountPickerItem::action( - "claude", - "Claude", - "Switch account `work`", - "a@example.com - valid - active", - AccountPickerCommand::SubmitInput("/account claude switch work".to_string()), - ), - AccountPickerItem::action( - "claude", - "Claude", - "Provider settings", - "configured", - AccountPickerCommand::SubmitInput("/account claude settings".to_string()), - ), - AccountPickerItem::action( - "openai", - "OpenAI", - "Switch account `default`", - "b@example.com - valid - active", - AccountPickerCommand::SubmitInput("/account openai switch default".to_string()), - ), - ], - ); - - picker.selected = 1; - let _ = picker.handle_overlay_key(KeyCode::Right, KeyModifiers::empty()); - assert_eq!( - picker.items[picker.filtered[picker.selected]].provider_id, - "openai" - ); - - let _ = picker.handle_overlay_key(KeyCode::Left, KeyModifiers::empty()); - assert_eq!( - picker.items[picker.filtered[picker.selected]].provider_id, - "claude" - ); - assert_eq!(picker.selected, 0); - } - #[test] fn account_picker_catalog_state_space_renders_and_executes_every_provider_action() { let providers = crate::provider_catalog::login_providers(); From 05ef2692d49b2d4b2da5230be7d4ddfed8727ff4 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:11:00 -0700 Subject: [PATCH 25/41] refactor(tui): isolate usage overlay crate --- Cargo.lock | 5 + crates/jcode-tui-usage-overlay/Cargo.toml | 5 + crates/jcode-tui-usage-overlay/src/lib.rs | 793 +++++++++++++++++++++- crates/jcode-tui/src/tui/usage_overlay.rs | 722 +------------------- 4 files changed, 804 insertions(+), 721 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36fb75ab9..ee5b236bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4135,8 +4135,13 @@ dependencies = [ name = "jcode-tui-usage-overlay" version = "0.1.0" dependencies = [ + "anyhow", + "chrono", + "crossterm", + "jcode-usage-types", "ratatui", "serde", + "serde_json", ] [[package]] diff --git a/crates/jcode-tui-usage-overlay/Cargo.toml b/crates/jcode-tui-usage-overlay/Cargo.toml index 65e174a44..c3403c3b7 100644 --- a/crates/jcode-tui-usage-overlay/Cargo.toml +++ b/crates/jcode-tui-usage-overlay/Cargo.toml @@ -5,8 +5,13 @@ edition = "2024" publish = false [dependencies] +anyhow = "1" +chrono = "0.4" +crossterm = "0.29" +jcode-usage-types = { path = "../jcode-usage-types" } ratatui = "0.30" serde = { version = "1", features = ["derive"], optional = true } +serde_json = "1" [features] default = [] diff --git a/crates/jcode-tui-usage-overlay/src/lib.rs b/crates/jcode-tui-usage-overlay/src/lib.rs index 977040893..c9b6a865f 100644 --- a/crates/jcode-tui-usage-overlay/src/lib.rs +++ b/crates/jcode-tui-usage-overlay/src/lib.rs @@ -1,4 +1,9 @@ -use ratatui::style::Color; +use anyhow::Result; +use crossterm::event::{KeyCode, KeyModifiers}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Paragraph, Wrap}, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -108,6 +113,765 @@ pub fn item_matches_filter(item: &UsageOverlayItem, filter: &str) -> bool { .all(|needle| haystack.contains(&needle.to_lowercase())) } +const PANEL_BG: Color = Color::Rgb(24, 28, 40); +const PANEL_BORDER: Color = Color::Rgb(90, 95, 110); +const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190); +const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); +const SELECTED_BG: Color = Color::Rgb(38, 42, 56); +const MUTED: Color = Color::Rgb(140, 146, 163); +const MUTED_DARK: Color = Color::Rgb(100, 106, 122); +const OVERLAY_PERCENT_X: u16 = 88; +const OVERLAY_PERCENT_Y: u16 = 74; + +#[derive(Debug, Clone)] +pub struct UsageOverlay { + title: String, + items: Vec, + filtered: Vec, + selected: usize, + filter: String, + summary: UsageOverlaySummary, +} + +pub enum OverlayAction { + Continue, + Close, +} + +impl UsageOverlay { + pub fn loading() -> Self { + Self::new( + " Usage ", + vec![UsageOverlayItem::new( + "loading", + "Refreshing usage", + "Fetching limits from connected providers", + UsageOverlayStatus::Loading, + vec![ + "Fetching usage limits from all connected providers...".to_string(), + "".to_string(), + "This view will update automatically when the usage report returns." + .to_string(), + ], + )], + UsageOverlaySummary::default(), + ) + } + + pub fn from_progress(progress: &jcode_usage_types::ProviderUsageProgress) -> Self { + Self::from_provider_reports( + &progress.results, + !progress.done, + progress.completed, + progress.total, + progress.from_cache, + ) + } + + pub fn from_provider_reports( + reports: &[jcode_usage_types::ProviderUsage], + refreshing: bool, + completed: usize, + total: usize, + from_cache: bool, + ) -> Self { + let mut items: Vec = reports.iter().map(provider_item).collect(); + + if refreshing { + let subtitle = if total > 0 { + format!("Refreshing providers ({}/{})", completed.min(total), total) + } else if from_cache { + "Showing cached usage while refreshing providers".to_string() + } else { + "Fetching usage limits from connected providers".to_string() + }; + items.push(UsageOverlayItem::new( + "refreshing", + "Refreshing usage", + subtitle, + UsageOverlayStatus::Loading, + vec![ + "## Live refresh".to_string(), + if from_cache { + "• Cached results are visible immediately.".to_string() + } else { + "• Waiting for provider responses.".to_string() + }, + if total > 0 { + format!( + "• Completed {}/{} provider checks.", + completed.min(total), + total + ) + } else { + "• Discovering connected providers.".to_string() + }, + "• This panel updates as each provider returns.".to_string(), + ], + )); + } else if items.is_empty() { + items.push(UsageOverlayItem::new( + "no-providers", + "No connected providers", + "Connect Claude or OpenAI OAuth to show usage limits", + UsageOverlayStatus::Info, + vec![ + "## No usage sources found".to_string(), + "• No providers with OAuth credentials were found.".to_string(), + "• Use `/login claude` or `/login openai` to connect a provider.".to_string(), + "• Then run `/usage` again.".to_string(), + ], + )); + } + + let mut summary = UsageOverlaySummary { + provider_count: reports.len(), + session_visible: false, + ..UsageOverlaySummary::default() + }; + for report in reports { + match provider_status(report) { + UsageOverlayStatus::Warning => summary.warning_count += 1, + UsageOverlayStatus::Critical => summary.critical_count += 1, + UsageOverlayStatus::Error => summary.error_count += 1, + _ => {} + } + } + + let title = if refreshing { + " Usage · refreshing " + } else { + " Usage " + }; + Self::new(title, items, summary) + } + + pub fn debug_memory_profile(&self) -> serde_json::Value { + let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum(); + let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::(); + let filter_bytes = self.filter.capacity(); + let title_bytes = self.title.capacity(); + let total_estimate_bytes = + items_estimate_bytes + filtered_estimate_bytes + filter_bytes + title_bytes; + + serde_json::json!({ + "items_count": self.items.len(), + "filtered_count": self.filtered.len(), + "selected": self.selected, + "title_bytes": title_bytes, + "filter_bytes": filter_bytes, + "items_estimate_bytes": items_estimate_bytes, + "filtered_estimate_bytes": filtered_estimate_bytes, + "total_estimate_bytes": total_estimate_bytes, + }) + } + + pub fn new( + title: impl Into, + items: Vec, + summary: UsageOverlaySummary, + ) -> Self { + let mut overlay = Self { + title: title.into(), + items, + filtered: Vec::new(), + selected: 0, + filter: String::new(), + summary, + }; + overlay.apply_filter(); + overlay + } + + pub fn selected_item_title(&self) -> Option<&str> { + self.selected_item().map(|item| item.title.as_str()) + } + + pub fn replace_preserving_view(&mut self, mut next: Self) { + let selected_id = self.selected_item().map(|item| item.id.clone()); + next.filter = self.filter.clone(); + next.apply_filter(); + if let Some(selected_id) = selected_id + && let Some(selected) = next + .filtered + .iter() + .position(|item_idx| next.items[*item_idx].id == selected_id) + { + next.selected = selected; + } + *self = next; + } + + pub fn selected_item_detail_text(&self) -> String { + self.selected_item() + .map(|item| item.detail_lines.join("\n")) + .unwrap_or_default() + } + + fn selected_item(&self) -> Option<&UsageOverlayItem> { + self.filtered + .get(self.selected) + .and_then(|idx| self.items.get(*idx)) + } + + fn apply_filter(&mut self) { + self.filtered = self + .items + .iter() + .enumerate() + .filter_map(|(idx, item)| item_matches_filter(item, &self.filter).then_some(idx)) + .collect(); + if self.selected >= self.filtered.len() { + self.selected = self.filtered.len().saturating_sub(1); + } + } + + pub fn handle_overlay_key( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) -> Result { + match code { + KeyCode::Esc => { + if !self.filter.is_empty() { + self.filter.clear(); + self.apply_filter(); + return Ok(OverlayAction::Continue); + } + return Ok(OverlayAction::Close); + } + KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(OverlayAction::Close); + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(OverlayAction::Close); + } + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 1).min(max); + } + KeyCode::PageUp | KeyCode::Char('K') => { + self.selected = self.selected.saturating_sub(6); + } + KeyCode::PageDown | KeyCode::Char('J') => { + let max = self.filtered.len().saturating_sub(1); + self.selected = (self.selected + 6).min(max); + } + KeyCode::Home | KeyCode::Char('g') => { + self.selected = 0; + } + KeyCode::End | KeyCode::Char('G') => { + self.selected = self.filtered.len().saturating_sub(1); + } + KeyCode::Backspace => { + if self.filter.pop().is_some() { + self.apply_filter(); + } + } + KeyCode::Char(c) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.filter.push(c); + self.apply_filter(); + } + _ => {} + } + Ok(OverlayAction::Continue) + } + + pub fn render(&self, frame: &mut Frame) { + let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area()); + + let block = Block::default() + .title(format!(" {} ", self.title)) + .title_bottom(Line::from(vec![ + hotkey(" Up/Down "), + Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), + hotkey(" type "), + Span::styled(" filter ", Style::default().fg(MUTED_DARK)), + hotkey(" /usage "), + Span::styled(" refresh ", Style::default().fg(MUTED_DARK)), + hotkey(" Esc "), + Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(PANEL_BORDER)); + frame.render_widget(block, area); + + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(10), + Constraint::Length(2), + ]) + .split(inner); + + self.render_header(frame, rows[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(39), Constraint::Percentage(61)]) + .split(rows[1]); + + self.render_item_list(frame, body[0]); + self.render_detail_pane(frame, body[1]); + + let footer = Paragraph::new(Line::from(vec![ + Span::styled("Focus ", Style::default().fg(MUTED_DARK)), + Span::styled( + "Use this panel to compare provider headroom and reset times without cluttering the chat transcript.", + Style::default().fg(MUTED), + ), + ])); + frame.render_widget(footer, rows[2]); + } + + fn render_header(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .title(Span::styled( + " Usage overview ", + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(SECTION_BORDER)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines = vec![ + Line::from(vec![ + Span::styled("Filter ", Style::default().fg(MUTED_DARK)), + Span::styled( + if self.filter.is_empty() { + "type provider or plan name".to_string() + } else { + self.filter.clone() + }, + if self.filter.is_empty() { + Style::default().fg(Color::Gray).italic() + } else { + Style::default().fg(Color::White) + }, + ), + Span::styled( + format!(" · {} results", self.filtered.len()), + Style::default().fg(MUTED_DARK), + ), + ]), + Line::from(vec![ + metric_span( + "providers", + self.summary.provider_count, + Color::Rgb(111, 214, 181), + ), + Span::raw(" "), + metric_span( + "watch", + self.summary.warning_count, + Color::Rgb(255, 196, 112), + ), + Span::raw(" "), + metric_span( + "high", + self.summary.critical_count, + Color::Rgb(255, 146, 110), + ), + Span::raw(" "), + metric_span( + "errors", + self.summary.error_count, + Color::Rgb(232, 134, 134), + ), + if self.summary.session_visible { + Span::styled(" · session included", Style::default().fg(MUTED_DARK)) + } else { + Span::raw("") + }, + ]), + ]; + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } + + fn render_item_list(&self, frame: &mut Frame, area: Rect) { + let title = if self.filtered.is_empty() { + " Sources ".to_string() + } else { + format!(" Sources ({}/{}) ", self.selected + 1, self.filtered.len()) + }; + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(PANEL_BORDER_ACTIVE)); + let inner = block.inner(area); + frame.render_widget(block, area); + + if self.filtered.is_empty() { + frame.render_widget( + Paragraph::new("No usage items match the current filter.") + .style(Style::default().fg(MUTED)) + .wrap(Wrap { trim: false }), + inner, + ); + return; + } + + let mut lines: Vec> = Vec::new(); + let mut selected_line = 0usize; + for (visible_idx, item_idx) in self.filtered.iter().enumerate() { + let item = &self.items[*item_idx]; + let selected = visible_idx == self.selected; + if selected { + selected_line = lines.len(); + } + let title_style = if selected { + Style::default().fg(Color::White).bg(SELECTED_BG).bold() + } else { + Style::default().fg(Color::White) + }; + let subtitle_style = if selected { + Style::default().fg(MUTED).bg(SELECTED_BG) + } else { + Style::default().fg(MUTED) + }; + let badge_style = Style::default() + .fg(item.status.color()) + .bg(if selected { SELECTED_BG } else { PANEL_BG }) + .bold(); + let marker = if selected { "›" } else { " " }; + lines.push(Line::from(vec![ + Span::styled( + format!("{} {} ", marker, item.status.icon()), + Style::default().fg(item.status.color()).bg(if selected { + SELECTED_BG + } else { + PANEL_BG + }), + ), + Span::styled( + truncate_with_ellipsis(&item.title, inner.width.saturating_sub(16) as usize), + title_style, + ), + Span::raw(" "), + Span::styled(format!("[{}]", item.status.label()), badge_style), + ])); + lines.push(Line::from(Span::styled( + format!(" {}", item.subtitle), + subtitle_style, + ))); + lines.push(Line::from("")); + } + + let visible_height = inner.height.max(1) as usize; + let scroll = selected_line.saturating_sub(visible_height.saturating_sub(3)); + frame.render_widget( + Paragraph::new(lines) + .scroll((scroll.min(u16::MAX as usize) as u16, 0)) + .wrap(Wrap { trim: false }), + inner, + ); + } + + fn render_detail_pane(&self, frame: &mut Frame, area: Rect) { + let selected = self.selected_item(); + let title = selected + .map(|item| format!(" {} · {} ", item.title, item.status.label())) + .unwrap_or_else(|| " Usage details ".to_string()); + let border_color = selected + .map(|item| item.status.color()) + .unwrap_or(PANEL_BORDER_ACTIVE); + let block = Block::default() + .title(Span::styled( + title, + Style::default().fg(Color::White).bold(), + )) + .borders(Borders::ALL) + .style(Style::default().bg(PANEL_BG)) + .border_style(Style::default().fg(border_color)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let lines: Vec> = match selected { + Some(item) => item + .detail_lines + .iter() + .map(|line| { + if line.is_empty() { + Line::from("") + } else if let Some(rest) = line.strip_prefix("## ") { + Line::from(Span::styled( + format!(" {}", rest), + Style::default().fg(Color::White).bold(), + )) + } else if let Some(rest) = line.strip_prefix("• ") { + Line::from(vec![ + Span::styled(" • ", Style::default().fg(MUTED_DARK)), + Span::styled(rest.to_string(), Style::default().fg(MUTED)), + ]) + } else { + Line::from(Span::styled(line.clone(), Style::default().fg(MUTED))) + } + }) + .collect(), + None => vec![Line::from(Span::styled( + "No usage item selected.", + Style::default().fg(MUTED), + ))], + }; + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + } +} + +fn estimate_item_bytes(item: &UsageOverlayItem) -> usize { + item.id.capacity() + + item.title.capacity() + + item.subtitle.capacity() + + item + .detail_lines + .iter() + .map(|value| value.capacity()) + .sum::() +} + +fn hotkey(text: &'static str) -> Span<'static> { + Span::styled(text, Style::default().fg(Color::White).bg(Color::DarkGray)) +} + +fn metric_span(label: &'static str, value: usize, color: Color) -> Span<'static> { + Span::styled( + format!("{} {}", label, value), + Style::default().fg(color).bold(), + ) +} + +fn provider_item(report: &jcode_usage_types::ProviderUsage) -> UsageOverlayItem { + let status = provider_status(report); + let subtitle = provider_subtitle(report); + UsageOverlayItem::new( + report.provider_name.clone(), + report.provider_name.clone(), + subtitle, + status, + provider_detail_lines(report), + ) +} + +fn provider_status(report: &jcode_usage_types::ProviderUsage) -> UsageOverlayStatus { + if report.error.is_some() { + return UsageOverlayStatus::Error; + } + if report.hard_limit_reached { + return UsageOverlayStatus::Critical; + } + let max_percent = report + .limits + .iter() + .map(|limit| limit.usage_percent) + .fold(0.0_f32, f32::max); + if max_percent >= 90.0 { + UsageOverlayStatus::Critical + } else if max_percent >= 70.0 { + UsageOverlayStatus::Warning + } else if report.limits.is_empty() && report.extra_info.is_empty() { + UsageOverlayStatus::Info + } else { + UsageOverlayStatus::Good + } +} + +fn provider_subtitle(report: &jcode_usage_types::ProviderUsage) -> String { + if let Some(error) = &report.error { + return truncate_with_ellipsis(error, 72); + } + if report.hard_limit_reached { + return "Hard limit reached".to_string(); + } + let mut parts = Vec::new(); + if let Some(limit) = report + .limits + .iter() + .max_by(|a, b| a.usage_percent.total_cmp(&b.usage_percent)) + { + let mut part = format!( + "{} {:.0}% used", + limit.name, + limit.usage_percent.clamp(0.0, 999.0) + ); + if let Some(reset) = limit.resets_at.as_deref() { + part.push_str(&format!(" · resets in {}", format_reset_time(reset))); + } + parts.push(part); + } + if let Some((key, value)) = report.extra_info.first() { + parts.push(format!("{}: {}", key, value)); + } + if parts.is_empty() { + "No usage data available".to_string() + } else { + truncate_with_ellipsis(&parts.join(" · "), 96) + } +} + +fn parse_reset_timestamp(timestamp: &str) -> Option> { + if let Ok(reset) = chrono::DateTime::parse_from_rfc3339(timestamp) { + Some(reset.with_timezone(&chrono::Utc)) + } else if let Ok(reset) = + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.fZ") + { + Some(reset.and_utc()) + } else { + None + } +} + +pub fn format_reset_time(timestamp: &str) -> String { + if let Some(reset) = parse_reset_timestamp(timestamp) { + let duration = reset.signed_duration_since(chrono::Utc::now()); + if duration.num_seconds() <= 0 { + return "now".to_string(); + } + if duration.num_seconds() < 60 { + return "1m".to_string(); + } + let days = duration.num_days(); + let hours = duration.num_hours() % 24; + let minutes = duration.num_minutes() % 60; + if days > 0 { + if hours > 0 { + format!("{}d {}h", days, hours) + } else if minutes > 0 { + format!("{}d {}m", days, minutes) + } else { + format!("{}d", days) + } + } else if hours > 0 { + format!("{}h {}m", hours, minutes) + } else { + format!("{}m", minutes) + } + } else { + timestamp.to_string() + } +} + +pub fn format_usage_bar(percent: f32, width: usize) -> String { + let filled = ((percent / 100.0) * width as f32).round() as usize; + let filled = filled.min(width); + let empty = width.saturating_sub(filled); + let bar: String = "█".repeat(filled) + &"░".repeat(empty); + format!("{} {:.0}%", bar, percent) +} + +fn provider_detail_lines(report: &jcode_usage_types::ProviderUsage) -> Vec { + let mut lines = Vec::new(); + lines.push("## Status".to_string()); + if let Some(error) = &report.error { + lines.push(format!("• Error: {}", error)); + lines.push("".to_string()); + lines.push("## Next steps".to_string()); + lines.push( + "• Re-run `/usage` to retry after credentials or network issues are fixed.".to_string(), + ); + if report.provider_name.to_lowercase().contains("openai") { + lines.push("• Use `/login openai` if the token needs refreshing.".to_string()); + } else if report.provider_name.to_lowercase().contains("anthropic") + || report.provider_name.to_lowercase().contains("claude") + { + lines.push("• Use `/login claude` if the token needs refreshing.".to_string()); + } + return lines; + } + + lines.push(format!("• {}", provider_status(report).label())); + if report.hard_limit_reached { + lines.push("• Hard limit reached.".to_string()); + } + + if !report.limits.is_empty() { + lines.push("".to_string()); + lines.push("## Limits".to_string()); + for limit in &report.limits { + let reset = limit + .resets_at + .as_deref() + .map(format_reset_time) + .map(|value| format!(" · resets in {}", value)) + .unwrap_or_default(); + lines.push(format!( + "• {} {}{}", + limit.name, + format_usage_bar(limit.usage_percent, 18), + reset + )); + } + } + + if !report.extra_info.is_empty() { + lines.push("".to_string()); + lines.push("## Details".to_string()); + for (key, value) in &report.extra_info { + lines.push(format!("• {}: {}", key, value)); + } + } + + if report.limits.is_empty() && report.extra_info.is_empty() { + lines.push("• No usage data available from this provider.".to_string()); + } + + lines +} + +fn truncate_with_ellipsis(input: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + let chars: Vec = input.chars().collect(); + if chars.len() <= width { + return input.to_string(); + } + if width <= 3 { + return ".".repeat(width); + } + let mut out: String = chars.into_iter().take(width - 3).collect(); + out.push_str("..."); + out +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup[1])[1] +} + #[cfg(test)] mod tests { use super::*; @@ -131,4 +895,31 @@ mod tests { assert!(item_matches_filter(&item, "claude 85")); assert!(!item_matches_filter(&item, "openai")); } + + #[test] + fn provider_reports_build_searchable_overlay_items() { + let overlay = UsageOverlay::from_provider_reports( + &[jcode_usage_types::ProviderUsage { + provider_name: "Claude".to_string(), + limits: vec![jcode_usage_types::UsageLimit { + name: "5h".to_string(), + usage_percent: 92.0, + resets_at: Some("2020-01-01T00:00:00Z".to_string()), + }], + extra_info: vec![("plan".to_string(), "max".to_string())], + hard_limit_reached: false, + error: None, + }], + false, + 1, + 1, + false, + ); + + assert_eq!(overlay.selected_item_title(), Some("Claude")); + let details = overlay.selected_item_detail_text(); + assert!(details.contains("## Limits")); + assert!(details.contains("5h")); + assert!(details.contains("now")); + } } diff --git a/crates/jcode-tui/src/tui/usage_overlay.rs b/crates/jcode-tui/src/tui/usage_overlay.rs index 3c48ed9a3..41eb5eec5 100644 --- a/crates/jcode-tui/src/tui/usage_overlay.rs +++ b/crates/jcode-tui/src/tui/usage_overlay.rs @@ -1,721 +1,3 @@ -use anyhow::Result; -use crossterm::event::{KeyCode, KeyModifiers}; -pub use jcode_tui_usage_overlay::{UsageOverlayItem, UsageOverlayStatus, UsageOverlaySummary}; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph, Wrap}, +pub use jcode_tui_usage_overlay::{ + OverlayAction, UsageOverlay, UsageOverlayItem, UsageOverlayStatus, UsageOverlaySummary, }; - -const PANEL_BG: Color = Color::Rgb(24, 28, 40); -const PANEL_BORDER: Color = Color::Rgb(90, 95, 110); -const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190); -const SECTION_BORDER: Color = Color::Rgb(70, 78, 94); -const SELECTED_BG: Color = Color::Rgb(38, 42, 56); -const MUTED: Color = Color::Rgb(140, 146, 163); -const MUTED_DARK: Color = Color::Rgb(100, 106, 122); -const OVERLAY_PERCENT_X: u16 = 88; -const OVERLAY_PERCENT_Y: u16 = 74; - -#[derive(Debug, Clone)] -pub struct UsageOverlay { - title: String, - items: Vec, - filtered: Vec, - selected: usize, - filter: String, - summary: UsageOverlaySummary, -} - -pub enum OverlayAction { - Continue, - Close, -} - -impl UsageOverlay { - pub fn loading() -> Self { - Self::new( - " Usage ", - vec![UsageOverlayItem::new( - "loading", - "Refreshing usage", - "Fetching limits from connected providers", - UsageOverlayStatus::Loading, - vec![ - "Fetching usage limits from all connected providers...".to_string(), - "".to_string(), - "This view will update automatically when the usage report returns." - .to_string(), - ], - )], - UsageOverlaySummary::default(), - ) - } - - pub fn from_progress(progress: &crate::usage::ProviderUsageProgress) -> Self { - Self::from_provider_reports( - &progress.results, - !progress.done, - progress.completed, - progress.total, - progress.from_cache, - ) - } - - pub fn from_provider_reports( - reports: &[crate::usage::ProviderUsage], - refreshing: bool, - completed: usize, - total: usize, - from_cache: bool, - ) -> Self { - let mut items: Vec = reports.iter().map(provider_item).collect(); - - if refreshing { - let subtitle = if total > 0 { - format!("Refreshing providers ({}/{})", completed.min(total), total) - } else if from_cache { - "Showing cached usage while refreshing providers".to_string() - } else { - "Fetching usage limits from connected providers".to_string() - }; - items.push(UsageOverlayItem::new( - "refreshing", - "Refreshing usage", - subtitle, - UsageOverlayStatus::Loading, - vec![ - "## Live refresh".to_string(), - if from_cache { - "• Cached results are visible immediately.".to_string() - } else { - "• Waiting for provider responses.".to_string() - }, - if total > 0 { - format!( - "• Completed {}/{} provider checks.", - completed.min(total), - total - ) - } else { - "• Discovering connected providers.".to_string() - }, - "• This panel updates as each provider returns.".to_string(), - ], - )); - } else if items.is_empty() { - items.push(UsageOverlayItem::new( - "no-providers", - "No connected providers", - "Connect Claude or OpenAI OAuth to show usage limits", - UsageOverlayStatus::Info, - vec![ - "## No usage sources found".to_string(), - "• No providers with OAuth credentials were found.".to_string(), - "• Use `/login claude` or `/login openai` to connect a provider.".to_string(), - "• Then run `/usage` again.".to_string(), - ], - )); - } - - let mut summary = UsageOverlaySummary { - provider_count: reports.len(), - session_visible: false, - ..UsageOverlaySummary::default() - }; - for report in reports { - match provider_status(report) { - UsageOverlayStatus::Warning => summary.warning_count += 1, - UsageOverlayStatus::Critical => summary.critical_count += 1, - UsageOverlayStatus::Error => summary.error_count += 1, - _ => {} - } - } - - let title = if refreshing { - " Usage · refreshing " - } else { - " Usage " - }; - Self::new(title, items, summary) - } - - pub fn debug_memory_profile(&self) -> serde_json::Value { - let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum(); - let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::(); - let filter_bytes = self.filter.capacity(); - let title_bytes = self.title.capacity(); - let total_estimate_bytes = - items_estimate_bytes + filtered_estimate_bytes + filter_bytes + title_bytes; - - serde_json::json!({ - "items_count": self.items.len(), - "filtered_count": self.filtered.len(), - "selected": self.selected, - "title_bytes": title_bytes, - "filter_bytes": filter_bytes, - "items_estimate_bytes": items_estimate_bytes, - "filtered_estimate_bytes": filtered_estimate_bytes, - "total_estimate_bytes": total_estimate_bytes, - }) - } - - pub fn new( - title: impl Into, - items: Vec, - summary: UsageOverlaySummary, - ) -> Self { - let mut overlay = Self { - title: title.into(), - items, - filtered: Vec::new(), - selected: 0, - filter: String::new(), - summary, - }; - overlay.apply_filter(); - overlay - } - - pub fn selected_item_title(&self) -> Option<&str> { - self.selected_item().map(|item| item.title.as_str()) - } - - pub fn replace_preserving_view(&mut self, mut next: Self) { - let selected_id = self.selected_item().map(|item| item.id.clone()); - next.filter = self.filter.clone(); - next.apply_filter(); - if let Some(selected_id) = selected_id - && let Some(selected) = next - .filtered - .iter() - .position(|item_idx| next.items[*item_idx].id == selected_id) - { - next.selected = selected; - } - *self = next; - } - - pub fn selected_item_detail_text(&self) -> String { - self.selected_item() - .map(|item| item.detail_lines.join("\n")) - .unwrap_or_default() - } - - fn selected_item(&self) -> Option<&UsageOverlayItem> { - self.filtered - .get(self.selected) - .and_then(|idx| self.items.get(*idx)) - } - - fn apply_filter(&mut self) { - self.filtered = self - .items - .iter() - .enumerate() - .filter_map(|(idx, item)| { - jcode_tui_usage_overlay::item_matches_filter(item, &self.filter).then_some(idx) - }) - .collect(); - if self.selected >= self.filtered.len() { - self.selected = self.filtered.len().saturating_sub(1); - } - } - - pub fn handle_overlay_key( - &mut self, - code: KeyCode, - modifiers: KeyModifiers, - ) -> Result { - match code { - KeyCode::Esc => { - if !self.filter.is_empty() { - self.filter.clear(); - self.apply_filter(); - return Ok(OverlayAction::Continue); - } - return Ok(OverlayAction::Close); - } - KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(OverlayAction::Close); - } - KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(OverlayAction::Close); - } - KeyCode::Up | KeyCode::Char('k') => { - self.selected = self.selected.saturating_sub(1); - } - KeyCode::Down | KeyCode::Char('j') => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 1).min(max); - } - KeyCode::PageUp | KeyCode::Char('K') => { - self.selected = self.selected.saturating_sub(6); - } - KeyCode::PageDown | KeyCode::Char('J') => { - let max = self.filtered.len().saturating_sub(1); - self.selected = (self.selected + 6).min(max); - } - KeyCode::Home | KeyCode::Char('g') => { - self.selected = 0; - } - KeyCode::End | KeyCode::Char('G') => { - self.selected = self.filtered.len().saturating_sub(1); - } - KeyCode::Backspace => { - if self.filter.pop().is_some() { - self.apply_filter(); - } - } - KeyCode::Char(c) - if !modifiers.contains(KeyModifiers::CONTROL) - && !modifiers.contains(KeyModifiers::ALT) => - { - self.filter.push(c); - self.apply_filter(); - } - _ => {} - } - Ok(OverlayAction::Continue) - } - - pub fn render(&self, frame: &mut Frame) { - let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area()); - - let block = Block::default() - .title(format!(" {} ", self.title)) - .title_bottom(Line::from(vec![ - hotkey(" Up/Down "), - Span::styled(" navigate ", Style::default().fg(MUTED_DARK)), - hotkey(" type "), - Span::styled(" filter ", Style::default().fg(MUTED_DARK)), - hotkey(" /usage "), - Span::styled(" refresh ", Style::default().fg(MUTED_DARK)), - hotkey(" Esc "), - Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)), - ])) - .borders(Borders::ALL) - .border_style(Style::default().fg(PANEL_BORDER)); - frame.render_widget(block, area); - - let inner = Rect { - x: area.x + 1, - y: area.y + 1, - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(2), - }; - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), - Constraint::Min(10), - Constraint::Length(2), - ]) - .split(inner); - - self.render_header(frame, rows[0]); - - let body = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(39), Constraint::Percentage(61)]) - .split(rows[1]); - - self.render_item_list(frame, body[0]); - self.render_detail_pane(frame, body[1]); - - let footer = Paragraph::new(Line::from(vec![ - Span::styled("Focus ", Style::default().fg(MUTED_DARK)), - Span::styled( - "Use this panel to compare provider headroom and reset times without cluttering the chat transcript.", - Style::default().fg(MUTED), - ), - ])); - frame.render_widget(footer, rows[2]); - } - - fn render_header(&self, frame: &mut Frame, area: Rect) { - let block = Block::default() - .title(Span::styled( - " Usage overview ", - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(SECTION_BORDER)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let lines = vec![ - Line::from(vec![ - Span::styled("Filter ", Style::default().fg(MUTED_DARK)), - Span::styled( - if self.filter.is_empty() { - "type provider or plan name".to_string() - } else { - self.filter.clone() - }, - if self.filter.is_empty() { - Style::default().fg(Color::Gray).italic() - } else { - Style::default().fg(Color::White) - }, - ), - Span::styled( - format!(" · {} results", self.filtered.len()), - Style::default().fg(MUTED_DARK), - ), - ]), - Line::from(vec![ - metric_span( - "providers", - self.summary.provider_count, - Color::Rgb(111, 214, 181), - ), - Span::raw(" "), - metric_span( - "watch", - self.summary.warning_count, - Color::Rgb(255, 196, 112), - ), - Span::raw(" "), - metric_span( - "high", - self.summary.critical_count, - Color::Rgb(255, 146, 110), - ), - Span::raw(" "), - metric_span( - "errors", - self.summary.error_count, - Color::Rgb(232, 134, 134), - ), - if self.summary.session_visible { - Span::styled(" · session included", Style::default().fg(MUTED_DARK)) - } else { - Span::raw("") - }, - ]), - ]; - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); - } - - fn render_item_list(&self, frame: &mut Frame, area: Rect) { - let title = if self.filtered.is_empty() { - " Sources ".to_string() - } else { - format!(" Sources ({}/{}) ", self.selected + 1, self.filtered.len()) - }; - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(PANEL_BORDER_ACTIVE)); - let inner = block.inner(area); - frame.render_widget(block, area); - - if self.filtered.is_empty() { - frame.render_widget( - Paragraph::new("No usage items match the current filter.") - .style(Style::default().fg(MUTED)) - .wrap(Wrap { trim: false }), - inner, - ); - return; - } - - let mut lines: Vec> = Vec::new(); - let mut selected_line = 0usize; - for (visible_idx, item_idx) in self.filtered.iter().enumerate() { - let item = &self.items[*item_idx]; - let selected = visible_idx == self.selected; - if selected { - selected_line = lines.len(); - } - let title_style = if selected { - Style::default().fg(Color::White).bg(SELECTED_BG).bold() - } else { - Style::default().fg(Color::White) - }; - let subtitle_style = if selected { - Style::default().fg(MUTED).bg(SELECTED_BG) - } else { - Style::default().fg(MUTED) - }; - let badge_style = Style::default() - .fg(item.status.color()) - .bg(if selected { SELECTED_BG } else { PANEL_BG }) - .bold(); - let marker = if selected { "›" } else { " " }; - lines.push(Line::from(vec![ - Span::styled( - format!("{} {} ", marker, item.status.icon()), - Style::default().fg(item.status.color()).bg(if selected { - SELECTED_BG - } else { - PANEL_BG - }), - ), - Span::styled( - truncate_with_ellipsis(&item.title, inner.width.saturating_sub(16) as usize), - title_style, - ), - Span::raw(" "), - Span::styled(format!("[{}]", item.status.label()), badge_style), - ])); - lines.push(Line::from(Span::styled( - format!(" {}", item.subtitle), - subtitle_style, - ))); - lines.push(Line::from("")); - } - - let visible_height = inner.height.max(1) as usize; - let scroll = selected_line.saturating_sub(visible_height.saturating_sub(3)); - frame.render_widget( - Paragraph::new(lines) - .scroll((scroll.min(u16::MAX as usize) as u16, 0)) - .wrap(Wrap { trim: false }), - inner, - ); - } - - fn render_detail_pane(&self, frame: &mut Frame, area: Rect) { - let selected = self.selected_item(); - let title = selected - .map(|item| format!(" {} · {} ", item.title, item.status.label())) - .unwrap_or_else(|| " Usage details ".to_string()); - let border_color = selected - .map(|item| item.status.color()) - .unwrap_or(PANEL_BORDER_ACTIVE); - let block = Block::default() - .title(Span::styled( - title, - Style::default().fg(Color::White).bold(), - )) - .borders(Borders::ALL) - .style(Style::default().bg(PANEL_BG)) - .border_style(Style::default().fg(border_color)); - let inner = block.inner(area); - frame.render_widget(block, area); - - let lines: Vec> = match selected { - Some(item) => item - .detail_lines - .iter() - .map(|line| { - if line.is_empty() { - Line::from("") - } else if let Some(rest) = line.strip_prefix("## ") { - Line::from(Span::styled( - format!(" {}", rest), - Style::default().fg(Color::White).bold(), - )) - } else if let Some(rest) = line.strip_prefix("• ") { - Line::from(vec![ - Span::styled(" • ", Style::default().fg(MUTED_DARK)), - Span::styled(rest.to_string(), Style::default().fg(MUTED)), - ]) - } else { - Line::from(Span::styled(line.clone(), Style::default().fg(MUTED))) - } - }) - .collect(), - None => vec![Line::from(Span::styled( - "No usage item selected.", - Style::default().fg(MUTED), - ))], - }; - - frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); - } -} - -fn estimate_item_bytes(item: &UsageOverlayItem) -> usize { - item.id.capacity() - + item.title.capacity() - + item.subtitle.capacity() - + item - .detail_lines - .iter() - .map(|value| value.capacity()) - .sum::() -} - -fn hotkey(text: &'static str) -> Span<'static> { - Span::styled(text, Style::default().fg(Color::White).bg(Color::DarkGray)) -} - -fn metric_span(label: &'static str, value: usize, color: Color) -> Span<'static> { - Span::styled( - format!("{} {}", label, value), - Style::default().fg(color).bold(), - ) -} - -fn provider_item(report: &crate::usage::ProviderUsage) -> UsageOverlayItem { - let status = provider_status(report); - let subtitle = provider_subtitle(report); - UsageOverlayItem::new( - report.provider_name.clone(), - report.provider_name.clone(), - subtitle, - status, - provider_detail_lines(report), - ) -} - -fn provider_status(report: &crate::usage::ProviderUsage) -> UsageOverlayStatus { - if report.error.is_some() { - return UsageOverlayStatus::Error; - } - if report.hard_limit_reached { - return UsageOverlayStatus::Critical; - } - let max_percent = report - .limits - .iter() - .map(|limit| limit.usage_percent) - .fold(0.0_f32, f32::max); - if max_percent >= 90.0 { - UsageOverlayStatus::Critical - } else if max_percent >= 70.0 { - UsageOverlayStatus::Warning - } else if report.limits.is_empty() && report.extra_info.is_empty() { - UsageOverlayStatus::Info - } else { - UsageOverlayStatus::Good - } -} - -fn provider_subtitle(report: &crate::usage::ProviderUsage) -> String { - if let Some(error) = &report.error { - return truncate_with_ellipsis(error, 72); - } - if report.hard_limit_reached { - return "Hard limit reached".to_string(); - } - let mut parts = Vec::new(); - if let Some(limit) = report - .limits - .iter() - .max_by(|a, b| a.usage_percent.total_cmp(&b.usage_percent)) - { - let mut part = format!( - "{} {:.0}% used", - limit.name, - limit.usage_percent.clamp(0.0, 999.0) - ); - if let Some(reset) = limit.resets_at.as_deref() { - part.push_str(&format!( - " · resets in {}", - crate::usage::format_reset_time(reset) - )); - } - parts.push(part); - } - if let Some((key, value)) = report.extra_info.first() { - parts.push(format!("{}: {}", key, value)); - } - if parts.is_empty() { - "No usage data available".to_string() - } else { - truncate_with_ellipsis(&parts.join(" · "), 96) - } -} - -fn provider_detail_lines(report: &crate::usage::ProviderUsage) -> Vec { - let mut lines = Vec::new(); - lines.push("## Status".to_string()); - if let Some(error) = &report.error { - lines.push(format!("• Error: {}", error)); - lines.push("".to_string()); - lines.push("## Next steps".to_string()); - lines.push( - "• Re-run `/usage` to retry after credentials or network issues are fixed.".to_string(), - ); - if report.provider_name.to_lowercase().contains("openai") { - lines.push("• Use `/login openai` if the token needs refreshing.".to_string()); - } else if report.provider_name.to_lowercase().contains("anthropic") - || report.provider_name.to_lowercase().contains("claude") - { - lines.push("• Use `/login claude` if the token needs refreshing.".to_string()); - } - return lines; - } - - lines.push(format!("• {}", provider_status(report).label())); - if report.hard_limit_reached { - lines.push("• Hard limit reached.".to_string()); - } - - if !report.limits.is_empty() { - lines.push("".to_string()); - lines.push("## Limits".to_string()); - for limit in &report.limits { - let reset = limit - .resets_at - .as_deref() - .map(crate::usage::format_reset_time) - .map(|value| format!(" · resets in {}", value)) - .unwrap_or_default(); - lines.push(format!( - "• {} {}{}", - limit.name, - crate::usage::format_usage_bar(limit.usage_percent, 18), - reset - )); - } - } - - if !report.extra_info.is_empty() { - lines.push("".to_string()); - lines.push("## Details".to_string()); - for (key, value) in &report.extra_info { - lines.push(format!("• {}: {}", key, value)); - } - } - - if report.limits.is_empty() && report.extra_info.is_empty() { - lines.push("• No usage data available from this provider.".to_string()); - } - - lines -} - -fn truncate_with_ellipsis(input: &str, width: usize) -> String { - if width == 0 { - return String::new(); - } - let chars: Vec = input.chars().collect(); - if chars.len() <= width { - return input.to_string(); - } - if width <= 3 { - return ".".repeat(width); - } - let mut out: String = chars.into_iter().take(width - 3).collect(); - out.push_str("..."); - out -} - -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let popup = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup[1])[1] -} From 26e83ad9073692ab998cecabeffcbcbf42c4e389 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:13:56 -0700 Subject: [PATCH 26/41] refactor(tui): make mermaid facade explicit --- crates/jcode-tui/src/tui/mermaid.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/mermaid.rs b/crates/jcode-tui/src/tui/mermaid.rs index b4222b38c..76fc06aca 100644 --- a/crates/jcode-tui/src/tui/mermaid.rs +++ b/crates/jcode-tui/src/tui/mermaid.rs @@ -1,4 +1,31 @@ -pub use jcode_tui_mermaid::*; +pub use jcode_tui_mermaid::{ + DiagramBlock, DiagramCacheKey, DiagramId, DiagramInfo, DiagramOrigin, DiagramRenderProfile, + DiagramRenderRequest, ImageStateInfo, MermaidCacheEntry, MermaidContent, MermaidDebugStats, + MermaidDebugStatsDelta, MermaidFlickerBenchmark, MermaidMemoryBenchmark, MermaidMemoryProfile, + MermaidTheme, MermaidTimingSummary, ProcessMemorySnapshot, RenderArtifact, RenderError, + RenderMode, RenderPriority, RenderResult, RenderStatus, RenderTarget, ScrollFrameInfo, + ScrollTestResult, TestRenderResult, active_diagram_count, clear_active_diagrams, clear_cache, + clear_image_state, clear_streaming_preview_diagram, current_preferred_aspect_ratio_bucket, + debug_cache, debug_flicker_benchmark, debug_image_state, debug_memory_benchmark, + debug_memory_profile, debug_render, debug_stats, debug_stats_json, debug_test_render, + debug_test_resize_stability, debug_test_scroll, deferred_render_epoch, + diagram_placeholder_lines, error_lines_for, error_to_lines, estimate_image_height, + evict_old_cache, get_active_diagrams, get_cached_path, get_cached_png, get_font_size, + image_protocol_available, image_widget_placeholder_markdown, init_picker, + invalidate_render_state, is_mermaid_lang, is_video_export_mode, normalize_aspect_ratio, + parse_image_placeholder, preferred_aspect_ratio_bucket, protocol_type, register_active_diagram, + register_external_image, register_inline_image, render_image_widget, render_image_widget_fit, + render_image_widget_scale, render_image_widget_viewport, render_image_widget_viewport_precise, + render_mermaid, render_mermaid_deferred, render_mermaid_deferred_with_registration, + render_mermaid_deferred_with_stream_scope, render_mermaid_sized, render_mermaid_untracked, + reset_debug_stats, restore_active_diagrams, result_to_content, result_to_lines, set_log_hooks, + set_memory_snapshot_hook, set_render_completed_hook, set_streaming_preview_diagram, + set_video_export_mode, snapshot_active_diagrams, with_preferred_aspect_ratio, + write_video_export_marker, +}; + +#[cfg(feature = "mmdr-size-api")] +pub use jcode_tui_mermaid::terminal_theme; pub fn install_jcode_mermaid_hooks() { jcode_tui_mermaid::set_log_hooks(crate::logging::info, crate::logging::warn); From e453afe3224fd6040b2ef896900e135e5b3daa94 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:31:49 -0700 Subject: [PATCH 27/41] refactor(app-core): make build facade explicit --- crates/jcode-app-core/src/build.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/jcode-app-core/src/build.rs b/crates/jcode-app-core/src/build.rs index 6ab7ebd76..2ba13b860 100644 --- a/crates/jcode-app-core/src/build.rs +++ b/crates/jcode-app-core/src/build.rs @@ -1 +1,27 @@ -pub use jcode_build_support::*; +pub use jcode_build_support::{ + BinaryChoice, BinaryVersionReport, BuildInfo, BuildManifest, CanaryStatus, CrashInfo, + DevBinarySourceMetadata, MigrationContext, PendingActivation, PublishedBuild, + SELFDEV_CARGO_PROFILE, SelfDevBuildCommand, SelfDevBuildTarget, SharedServerRepair, + SourceState, advance_shared_server_if_tracking_stable, binary_name, binary_stem, + build_log_path, build_progress_path, builds_dir, canary_binary_path, clear_build_progress, + clear_migration_context, client_update_candidate, complete_pending_activation_for_session, + current_binary_build_time_string, current_binary_built_at, current_binary_path, + current_build_info, current_git_diff, current_git_hash, current_git_hash_full, + current_source_state, current_version_file, ensure_source_state_matches, find_dev_binary, + find_repo_in_ancestors, get_commit_message, get_repo_dir, install_binary_at_version, + install_local_release, install_version, is_jcode_repo, is_working_tree_dirty, + launcher_binary_path, launcher_dir, load_migration_context, manifest_path, + migration_context_path, preferred_reload_candidate, promote_version_to_shared_server, + publish_local_current_build, publish_local_current_build_for_source, read_build_progress, + read_current_version, read_shared_server_version, read_stable_version, release_binary_path, + repair_stale_shared_server_channel, repo_build_version, repo_scope_key, + rollback_pending_activation_for_session, run_selfdev_build, save_migration_context, + selfdev_binary_path, selfdev_build_command, selfdev_build_command_for_target, + shared_server_binary_path, shared_server_tracks_stable, shared_server_update_candidate, + shared_server_version_file, smoke_test_binary, smoke_test_server_binary, stable_binary_path, + stable_version_file, update_canary_symlink, update_current_symlink, + update_launcher_symlink_to_current, update_launcher_symlink_to_stable, + update_shared_server_symlink, update_stable_symlink, version_binary_path, + version_matches_installed_channel, worktree_scope_key, write_build_progress, + write_current_dev_binary_source_metadata, write_dev_binary_source_metadata, +}; From 7f59057dccd13d74f1779dbcdb9987d2cb2fb3ec Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:38:07 -0700 Subject: [PATCH 28/41] refactor(app-core): make ambient facades explicit --- crates/jcode-app-core/src/ambient_runner.rs | 2 +- crates/jcode-app-core/src/ambient_scheduler.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/jcode-app-core/src/ambient_runner.rs b/crates/jcode-app-core/src/ambient_runner.rs index 26a04d3b5..b621b4902 100644 --- a/crates/jcode-app-core/src/ambient_runner.rs +++ b/crates/jcode-app-core/src/ambient_runner.rs @@ -1,3 +1,3 @@ #![cfg_attr(test, allow(clippy::await_holding_lock))] -pub use crate::ambient::runner::*; +pub use crate::ambient::runner::AmbientRunnerHandle; diff --git a/crates/jcode-app-core/src/ambient_scheduler.rs b/crates/jcode-app-core/src/ambient_scheduler.rs index 19fac30f9..e74d2a3fe 100644 --- a/crates/jcode-app-core/src/ambient_scheduler.rs +++ b/crates/jcode-app-core/src/ambient_scheduler.rs @@ -1 +1,3 @@ -pub use crate::ambient::scheduler::*; +pub use crate::ambient::scheduler::{ + AdaptiveScheduler, AmbientSchedulerConfig, RateLimitInfo, UsageLog, UsageRecord, UsageSource, +}; From 26cf544466cb965e7ad1672aa17bc9fb61027625 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:48:47 -0700 Subject: [PATCH 29/41] refactor(provider): isolate env credential helpers --- Cargo.lock | 13 + Cargo.toml | 1 + crates/jcode-base/Cargo.toml | 1 + crates/jcode-base/src/provider_catalog.rs | 166 +------------ crates/jcode-provider-env/Cargo.toml | 18 ++ crates/jcode-provider-env/src/lib.rs | 275 ++++++++++++++++++++++ 6 files changed, 312 insertions(+), 162 deletions(-) create mode 100644 crates/jcode-provider-env/Cargo.toml create mode 100644 crates/jcode-provider-env/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ee5b236bc..8d1876400 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3556,6 +3556,7 @@ dependencies = [ "jcode-plan", "jcode-protocol", "jcode-provider-core", + "jcode-provider-env", "jcode-provider-gemini", "jcode-provider-metadata", "jcode-provider-openai", @@ -3841,6 +3842,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "jcode-provider-env" +version = "0.1.0" +dependencies = [ + "anyhow", + "jcode-core", + "jcode-logging", + "jcode-provider-metadata", + "jcode-storage", + "tempfile", +] + [[package]] name = "jcode-provider-gemini" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 065cfef67..f3d4d94d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ members = [ "crates/jcode-azure-auth", "crates/jcode-notify-email", "crates/jcode-provider-metadata", + "crates/jcode-provider-env", "crates/jcode-provider-core", "crates/jcode-provider-openrouter", "crates/jcode-provider-openai", diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index fe36db408..27de52002 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -69,6 +69,7 @@ jcode-auth-types = { path = "../jcode-auth-types" } jcode-azure-auth = { path = "../jcode-azure-auth" } jcode-agent-runtime = { path = "../jcode-agent-runtime" } jcode-provider-metadata = { path = "../jcode-provider-metadata" } +jcode-provider-env = { path = "../jcode-provider-env" } jcode-provider-core = { path = "../jcode-provider-core" } jcode-provider-openai = { path = "../jcode-provider-openai" } jcode-provider-openrouter = { path = "../jcode-provider-openrouter" } diff --git a/crates/jcode-base/src/provider_catalog.rs b/crates/jcode-base/src/provider_catalog.rs index 2115f6e7a..f1f5c881a 100644 --- a/crates/jcode-base/src/provider_catalog.rs +++ b/crates/jcode-base/src/provider_catalog.rs @@ -1,39 +1,9 @@ +pub use jcode_provider_env::{ + load_api_key_from_env_or_config, load_env_value_from_env_or_config, + register_api_key_fallback_resolver, save_env_value_to_env_file, +}; pub use jcode_provider_metadata::*; use std::collections::{HashMap, HashSet}; -use std::sync::{LazyLock, RwLock}; - -/// Fallback resolvers consulted by [`load_api_key_from_env_or_config`] after the -/// environment and config-file lookups fail. Higher-level crates (notably -/// `auth`, which scans trusted external CLI credential stores) register a -/// resolver at startup so `provider_catalog` does not need to depend on `auth`. -type ApiKeyFallbackResolver = fn(&str) -> Option; - -static API_KEY_FALLBACK_RESOLVERS: LazyLock>> = - LazyLock::new(|| RwLock::new(Vec::new())); - -/// Register a fallback API-key resolver consulted when env/config lookups miss. -/// -/// This inverts the historical `provider_catalog -> auth` dependency: `auth` -/// (the higher layer) now registers its external-credential scan here, keeping -/// `provider_catalog` free of upward references. -pub fn register_api_key_fallback_resolver(resolver: ApiKeyFallbackResolver) { - API_KEY_FALLBACK_RESOLVERS - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push(resolver); -} - -fn resolve_api_key_fallback(env_key: &str) -> Option { - let resolvers = API_KEY_FALLBACK_RESOLVERS - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - for resolver in resolvers.iter() { - if let Some(key) = resolver(env_key) { - return Some(key); - } - } - None -} pub const OPENAI_COMPAT_LOCAL_ENABLED_ENV: &str = "JCODE_OPENAI_COMPAT_LOCAL_ENABLED"; pub const MINIMAX_CHINA_API_BASE: &str = "https://api.minimaxi.com/v1"; @@ -855,134 +825,6 @@ pub fn configured_api_key_source( Some((env_key, file_name)) } -pub fn load_api_key_from_env_or_config(env_key: &str, file_name: &str) -> Option { - if !is_safe_env_key_name(env_key) { - crate::logging::warn(&format!( - "Ignoring invalid API key variable name '{}' while loading credentials", - env_key - )); - return None; - } - if !is_safe_env_file_name(file_name) { - crate::logging::warn(&format!( - "Ignoring invalid env file name '{}' while loading credentials", - file_name - )); - return None; - } - - if let Ok(key) = std::env::var(env_key) { - let key = key.trim(); - if !key.is_empty() { - return Some(key.to_string()); - } - } - - let config_path = crate::storage::app_config_dir().ok()?.join(file_name); - crate::storage::harden_secret_file_permissions(&config_path); - let content = std::fs::read_to_string(config_path).ok()?; - let prefix = format!("{}=", env_key); - - for line in content.lines() { - if let Some(key) = line.strip_prefix(&prefix) { - let key = key.trim().trim_matches('"').trim_matches('\''); - if !key.is_empty() { - return Some(key.to_string()); - } - } - } - - if env_key == "ZHIPU_API_KEY" { - if let Ok(key) = std::env::var("ZAI_API_KEY") { - let key = key.trim(); - if !key.is_empty() { - return Some(key.to_string()); - } - } - - let legacy_prefix = "ZAI_API_KEY="; - for line in content.lines() { - if let Some(key) = line.strip_prefix(legacy_prefix) { - let key = key.trim().trim_matches('"').trim_matches('\''); - if !key.is_empty() { - return Some(key.to_string()); - } - } - } - } - - if let Some(key) = resolve_api_key_fallback(env_key) { - return Some(key); - } - - None -} - -pub fn load_env_value_from_env_or_config(env_key: &str, file_name: &str) -> Option { - if !is_safe_env_key_name(env_key) { - crate::logging::warn(&format!( - "Ignoring invalid variable name '{}' while loading config value", - env_key - )); - return None; - } - if !is_safe_env_file_name(file_name) { - crate::logging::warn(&format!( - "Ignoring invalid env file name '{}' while loading config value", - file_name - )); - return None; - } - - if let Ok(value) = std::env::var(env_key) { - let value = value.trim(); - if !value.is_empty() { - return Some(value.to_string()); - } - } - - let config_path = crate::storage::app_config_dir().ok()?.join(file_name); - crate::storage::harden_secret_file_permissions(&config_path); - let content = std::fs::read_to_string(config_path).ok()?; - let prefix = format!("{}=", env_key); - - for line in content.lines() { - if let Some(value) = line.strip_prefix(&prefix) { - let value = value.trim().trim_matches('"').trim_matches('\''); - if !value.is_empty() { - return Some(value.to_string()); - } - } - } - - None -} - -pub fn save_env_value_to_env_file( - env_key: &str, - file_name: &str, - value: Option<&str>, -) -> anyhow::Result<()> { - if !is_safe_env_key_name(env_key) { - anyhow::bail!("Invalid variable name: {}", env_key); - } - if !is_safe_env_file_name(file_name) { - anyhow::bail!("Invalid env file name: {}", file_name); - } - - let config_dir = crate::storage::app_config_dir()?; - let file_path = config_dir.join(file_name); - crate::storage::upsert_env_file_value(&file_path, env_key, value)?; - - if let Some(value) = value { - crate::env::set_var(env_key, value); - } else { - crate::env::remove_var(env_key); - } - - Ok(()) -} - fn env_override(name: &str) -> Option { std::env::var(name) .ok() diff --git a/crates/jcode-provider-env/Cargo.toml b/crates/jcode-provider-env/Cargo.toml new file mode 100644 index 000000000..09d6004df --- /dev/null +++ b/crates/jcode-provider-env/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "jcode-provider-env" +version = "0.1.0" +edition = "2024" + +[lib] +name = "jcode_provider_env" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +jcode-core = { path = "../jcode-core" } +jcode-logging = { path = "../jcode-logging" } +jcode-provider-metadata = { path = "../jcode-provider-metadata" } +jcode-storage = { path = "../jcode-storage" } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/jcode-provider-env/src/lib.rs b/crates/jcode-provider-env/src/lib.rs new file mode 100644 index 000000000..4d0e75855 --- /dev/null +++ b/crates/jcode-provider-env/src/lib.rs @@ -0,0 +1,275 @@ +use std::sync::{LazyLock, RwLock}; + +use jcode_provider_metadata::{is_safe_env_file_name, is_safe_env_key_name}; + +/// Fallback resolvers consulted by [`load_api_key_from_env_or_config`] after the +/// environment and config-file lookups fail. Higher-level crates register +/// resolvers at startup so this leaf crate does not need to depend on auth. +type ApiKeyFallbackResolver = fn(&str) -> Option; + +static API_KEY_FALLBACK_RESOLVERS: LazyLock>> = + LazyLock::new(|| RwLock::new(Vec::new())); + +/// Register a fallback API-key resolver consulted when env/config lookups miss. +pub fn register_api_key_fallback_resolver(resolver: ApiKeyFallbackResolver) { + API_KEY_FALLBACK_RESOLVERS + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(resolver); +} + +fn resolve_api_key_fallback(env_key: &str) -> Option { + let resolvers = API_KEY_FALLBACK_RESOLVERS + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for resolver in resolvers.iter() { + if let Some(key) = resolver(env_key) { + return Some(key); + } + } + None +} + +pub fn load_api_key_from_env_or_config(env_key: &str, file_name: &str) -> Option { + if !is_safe_env_key_name(env_key) { + jcode_logging::warn(&format!( + "Ignoring invalid API key variable name '{}' while loading credentials", + env_key + )); + return None; + } + if !is_safe_env_file_name(file_name) { + jcode_logging::warn(&format!( + "Ignoring invalid env file name '{}' while loading credentials", + file_name + )); + return None; + } + + if let Ok(key) = std::env::var(env_key) { + let key = key.trim(); + if !key.is_empty() { + return Some(key.to_string()); + } + } + + let config_path = jcode_storage::app_config_dir().ok()?.join(file_name); + jcode_storage::harden_secret_file_permissions(&config_path); + let content = std::fs::read_to_string(config_path).ok()?; + let prefix = format!("{}=", env_key); + + for line in content.lines() { + if let Some(key) = line.strip_prefix(&prefix) { + let key = key.trim().trim_matches('"').trim_matches('\''); + if !key.is_empty() { + return Some(key.to_string()); + } + } + } + + if env_key == "ZHIPU_API_KEY" { + if let Ok(key) = std::env::var("ZAI_API_KEY") { + let key = key.trim(); + if !key.is_empty() { + return Some(key.to_string()); + } + } + + let legacy_prefix = "ZAI_API_KEY="; + for line in content.lines() { + if let Some(key) = line.strip_prefix(legacy_prefix) { + let key = key.trim().trim_matches('"').trim_matches('\''); + if !key.is_empty() { + return Some(key.to_string()); + } + } + } + } + + if let Some(key) = resolve_api_key_fallback(env_key) { + return Some(key); + } + + None +} + +pub fn load_env_value_from_env_or_config(env_key: &str, file_name: &str) -> Option { + if !is_safe_env_key_name(env_key) { + jcode_logging::warn(&format!( + "Ignoring invalid variable name '{}' while loading config value", + env_key + )); + return None; + } + if !is_safe_env_file_name(file_name) { + jcode_logging::warn(&format!( + "Ignoring invalid env file name '{}' while loading config value", + file_name + )); + return None; + } + + if let Ok(value) = std::env::var(env_key) { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + + let config_path = jcode_storage::app_config_dir().ok()?.join(file_name); + jcode_storage::harden_secret_file_permissions(&config_path); + let content = std::fs::read_to_string(config_path).ok()?; + let prefix = format!("{}=", env_key); + + for line in content.lines() { + if let Some(value) = line.strip_prefix(&prefix) { + let value = value.trim().trim_matches('"').trim_matches('\''); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} + +pub fn save_env_value_to_env_file( + env_key: &str, + file_name: &str, + value: Option<&str>, +) -> anyhow::Result<()> { + if !is_safe_env_key_name(env_key) { + anyhow::bail!("Invalid variable name: {}", env_key); + } + if !is_safe_env_file_name(file_name) { + anyhow::bail!("Invalid env file name: {}", file_name); + } + + let config_dir = jcode_storage::app_config_dir()?; + let file_path = config_dir.join(file_name); + jcode_storage::upsert_env_file_value(&file_path, env_key, value)?; + + if let Some(value) = value { + jcode_core::env::set_var(env_key, value); + } else { + jcode_core::env::remove_var(env_key); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + use std::sync::{Mutex, MutexGuard}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + _lock: MutexGuard<'static, ()>, + saved: Vec<(&'static str, Option)>, + } + + impl EnvGuard { + fn new(keys: &[&'static str]) -> Self { + let lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let saved = keys + .iter() + .map(|key| (*key, std::env::var_os(key))) + .collect::>(); + for key in keys { + jcode_core::env::remove_var(key); + } + Self { _lock: lock, saved } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in self.saved.drain(..) { + match value { + Some(value) => jcode_core::env::set_var(key, value), + None => jcode_core::env::remove_var(key), + } + } + } + } + + #[test] + fn loads_api_key_from_env_before_config_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::new(&["JCODE_HOME", "JCODE_PROVIDER_ENV_TEST_KEY"]); + jcode_core::env::set_var("JCODE_HOME", temp.path()); + + save_env_value_to_env_file( + "JCODE_PROVIDER_ENV_TEST_KEY", + "provider-env-test.env", + Some("file-key"), + ) + .expect("save file key"); + jcode_core::env::set_var("JCODE_PROVIDER_ENV_TEST_KEY", "env-key"); + + assert_eq!( + load_api_key_from_env_or_config("JCODE_PROVIDER_ENV_TEST_KEY", "provider-env-test.env") + .as_deref(), + Some("env-key") + ); + } + + #[test] + fn loads_and_removes_values_from_sandboxed_config_file() { + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::new(&["JCODE_HOME", "JCODE_PROVIDER_ENV_TEST_VALUE"]); + jcode_core::env::set_var("JCODE_HOME", temp.path()); + + save_env_value_to_env_file( + "JCODE_PROVIDER_ENV_TEST_VALUE", + "provider-env-test.env", + Some("file-value"), + ) + .expect("save file value"); + + jcode_core::env::remove_var("JCODE_PROVIDER_ENV_TEST_VALUE"); + assert_eq!( + load_env_value_from_env_or_config( + "JCODE_PROVIDER_ENV_TEST_VALUE", + "provider-env-test.env" + ) + .as_deref(), + Some("file-value") + ); + + save_env_value_to_env_file( + "JCODE_PROVIDER_ENV_TEST_VALUE", + "provider-env-test.env", + None, + ) + .expect("remove file value"); + assert_eq!( + load_env_value_from_env_or_config( + "JCODE_PROVIDER_ENV_TEST_VALUE", + "provider-env-test.env" + ), + None + ); + } + + #[test] + fn accepts_legacy_zai_key_for_zhipu() { + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = EnvGuard::new(&["JCODE_HOME", "ZHIPU_API_KEY", "ZAI_API_KEY"]); + jcode_core::env::set_var("JCODE_HOME", temp.path()); + + save_env_value_to_env_file("ZAI_API_KEY", "zai.env", Some("legacy-zai-key")) + .expect("save legacy key"); + jcode_core::env::remove_var("ZAI_API_KEY"); + + assert_eq!( + load_api_key_from_env_or_config("ZHIPU_API_KEY", "zai.env").as_deref(), + Some("legacy-zai-key") + ); + } +} From c9890474c0c4192f22aa05101544e76a88e600c3 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:55:35 -0700 Subject: [PATCH 30/41] refactor(provider): move fingerprint helper into provider core --- Cargo.lock | 2 + crates/jcode-base/src/provider/fingerprint.rs | 205 +----------------- crates/jcode-provider-core/Cargo.toml | 2 + crates/jcode-provider-core/src/fingerprint.rs | 202 +++++++++++++++++ crates/jcode-provider-core/src/lib.rs | 10 +- 5 files changed, 215 insertions(+), 206 deletions(-) create mode 100644 crates/jcode-provider-core/src/fingerprint.rs diff --git a/Cargo.lock b/Cargo.lock index 8d1876400..239aa1037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3835,10 +3835,12 @@ dependencies = [ "anyhow", "async-trait", "futures", + "jcode-logging", "jcode-message-types", "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "tokio", ] diff --git a/crates/jcode-base/src/provider/fingerprint.rs b/crates/jcode-base/src/provider/fingerprint.rs index 78360a37f..38773b2b5 100644 --- a/crates/jcode-base/src/provider/fingerprint.rs +++ b/crates/jcode-base/src/provider/fingerprint.rs @@ -1,202 +1,3 @@ -use serde::Serialize; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; -use std::time::Instant; - -#[derive(Debug, Clone)] -struct ProviderInputSnapshot { - request_hash: u64, - item_hashes: Vec, - item_hashes_hash: u64, - system_hash: Option, - tools_hash: Option, - captured_at: Instant, -} - -static PROVIDER_INPUT_BASELINES: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -pub(crate) fn stable_hash_str(value: &str) -> u64 { - let digest = Sha256::digest(value.as_bytes()); - let mut bytes = [0_u8; 8]; - bytes.copy_from_slice(&digest[..8]); - u64::from_be_bytes(bytes) -} - -pub(crate) fn stable_hash_json(value: &T) -> u64 { - let encoded = serde_json::to_string(value).unwrap_or_default(); - stable_hash_str(&encoded) -} - -fn stable_json_len(value: &T) -> usize { - serde_json::to_string(value) - .map(|encoded| encoded.len()) - .unwrap_or_default() -} - -fn item_hashes(items: &[Value]) -> Vec { - items.iter().map(stable_hash_json).collect() -} - -fn prefix_matches(current: &[u64], previous: &[u64]) -> bool { - if previous.len() > current.len() { - return false; - } - current[..previous.len()] == *previous -} - -fn common_prefix_len(current: &[u64], previous: &[u64]) -> usize { - current - .iter() - .zip(previous.iter()) - .take_while(|(current, previous)| current == previous) - .count() -} - -/// Log a privacy-preserving fingerprint of the provider-specific prompt payload. -/// -/// `payload` should be the prompt/cache-relevant request shape after provider-specific -/// normalization, not the high-level Jcode message list. Do not include volatile transport -/// IDs unless they are intentionally part of the cache key. `items` should be the ordered -/// provider-visible message/content array so prefix drift can be diagnosed by index. -#[allow(clippy::too_many_arguments)] -pub(crate) fn log_provider_canonical_input( - provider: &str, - model: &str, - format: &str, - payload: &Value, - items: &[Value], - system: Option<&Value>, - tools: Option<&Value>, - tool_count: Option, - extra_fields: &[(&str, String)], -) { - let request_hash = stable_hash_json(payload); - let request_json_chars = stable_json_len(payload); - let item_hashes = item_hashes(items); - let item_hashes_hash = stable_hash_json(&item_hashes); - let input_hash = stable_hash_json(items); - let system_hash = system.map(stable_hash_json); - let system_json_chars = system.map(stable_json_len); - let tools_hash = tools.map(stable_hash_json); - let tools_json_chars = tools.map(stable_json_len); - let first_item_hash = item_hashes.first().copied(); - let last_item_hash = item_hashes.last().copied(); - - let log_context = crate::logging::current_context_snapshot(); - let session_key = log_context.session.as_deref().unwrap_or("no-session"); - let key = format!( - "{}\u{1f}{}\u{1f}{}\u{1f}{}", - session_key, provider, model, format - ); - let snapshot = ProviderInputSnapshot { - request_hash, - item_hashes: item_hashes.clone(), - item_hashes_hash, - system_hash, - tools_hash, - captured_at: Instant::now(), - }; - - let previous = PROVIDER_INPUT_BASELINES - .lock() - .map(|mut baselines| baselines.insert(key, snapshot)) - .ok() - .flatten(); - - let previous_age_secs = previous - .as_ref() - .map(|previous| previous.captured_at.elapsed().as_secs()); - let request_changed = previous - .as_ref() - .map(|previous| previous.request_hash != request_hash); - let item_hashes_changed = previous - .as_ref() - .map(|previous| previous.item_hashes_hash != item_hashes_hash); - let prefix_matches = previous - .as_ref() - .map(|previous| prefix_matches(&item_hashes, &previous.item_hashes)); - let common_prefix_items = previous - .as_ref() - .map(|previous| common_prefix_len(&item_hashes, &previous.item_hashes)); - let first_changed_item_index = common_prefix_items - .zip(previous.as_ref().map(|previous| previous.item_hashes.len())) - .and_then(|(common, previous_len)| (common < previous_len).then_some(common)); - let previous_item_count = previous.as_ref().map(|previous| previous.item_hashes.len()); - let system_changed = previous - .as_ref() - .map(|previous| previous.system_hash != system_hash); - let tools_changed = previous - .as_ref() - .map(|previous| previous.tools_hash != tools_hash); - - let mut extras = String::new(); - for (key, value) in extra_fields { - if !key.is_empty() && !value.is_empty() { - extras.push(' '); - extras.push_str(key); - extras.push('='); - extras.push_str(value); - } - } - - crate::logging::info(&format!( - "PROVIDER_CANONICAL_INPUT: provider={} model={} format={} request_hash={} request_json_chars={} \ - input_hash={} item_count={} previous_item_count={:?} item_hashes_hash={} first_item_hash={:?} last_item_hash={:?} \ - previous_age_secs={:?} prefix_matches={:?} common_prefix_items={:?} first_changed_item_index={:?} \ - request_changed={:?} item_hashes_changed={:?} system_hash={:?} system_json_chars={:?} system_changed={:?} \ - tools_hash={:?} tools_json_chars={:?} tool_count={:?} tools_changed={:?}{}", - provider, - model, - format, - request_hash, - request_json_chars, - input_hash, - items.len(), - previous_item_count, - item_hashes_hash, - first_item_hash, - last_item_hash, - previous_age_secs, - prefix_matches, - common_prefix_items, - first_changed_item_index, - request_changed, - item_hashes_changed, - system_hash, - system_json_chars, - system_changed, - tools_hash, - tools_json_chars, - tool_count, - tools_changed, - extras, - )); -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn prefix_matching_allows_append_only_growth() { - assert!(prefix_matches(&[1, 2, 3], &[1, 2])); - } - - #[test] - fn prefix_matching_detects_changed_prefix() { - assert!(!prefix_matches(&[1, 9, 3], &[1, 2])); - assert_eq!(common_prefix_len(&[1, 9, 3], &[1, 2]), 1); - } - - #[test] - fn json_hashes_are_content_sensitive() { - assert_ne!( - stable_hash_json(&json!({"a": 1})), - stable_hash_json(&json!({"a": 2})) - ); - } -} +pub(crate) use jcode_provider_core::fingerprint::{ + log_provider_canonical_input, stable_hash_json, stable_hash_str, +}; diff --git a/crates/jcode-provider-core/Cargo.toml b/crates/jcode-provider-core/Cargo.toml index f6a606214..00c7d85b1 100644 --- a/crates/jcode-provider-core/Cargo.toml +++ b/crates/jcode-provider-core/Cargo.toml @@ -11,8 +11,10 @@ path = "src/lib.rs" anyhow = "1" async-trait = "0.1" futures = "0.3" +jcode-logging = { path = "../jcode-logging" } jcode-message-types = { path = "../jcode-message-types" } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" tokio = { version = "1", features = ["sync"] } diff --git a/crates/jcode-provider-core/src/fingerprint.rs b/crates/jcode-provider-core/src/fingerprint.rs new file mode 100644 index 000000000..ed16135bf --- /dev/null +++ b/crates/jcode-provider-core/src/fingerprint.rs @@ -0,0 +1,202 @@ +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; +use std::time::Instant; + +#[derive(Debug, Clone)] +struct ProviderInputSnapshot { + request_hash: u64, + item_hashes: Vec, + item_hashes_hash: u64, + system_hash: Option, + tools_hash: Option, + captured_at: Instant, +} + +static PROVIDER_INPUT_BASELINES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +pub fn stable_hash_str(value: &str) -> u64 { + let digest = Sha256::digest(value.as_bytes()); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + u64::from_be_bytes(bytes) +} + +pub fn stable_hash_json(value: &T) -> u64 { + let encoded = serde_json::to_string(value).unwrap_or_default(); + stable_hash_str(&encoded) +} + +fn stable_json_len(value: &T) -> usize { + serde_json::to_string(value) + .map(|encoded| encoded.len()) + .unwrap_or_default() +} + +fn item_hashes(items: &[Value]) -> Vec { + items.iter().map(stable_hash_json).collect() +} + +fn prefix_matches(current: &[u64], previous: &[u64]) -> bool { + if previous.len() > current.len() { + return false; + } + current[..previous.len()] == *previous +} + +fn common_prefix_len(current: &[u64], previous: &[u64]) -> usize { + current + .iter() + .zip(previous.iter()) + .take_while(|(current, previous)| current == previous) + .count() +} + +/// Log a privacy-preserving fingerprint of the provider-specific prompt payload. +/// +/// `payload` should be the prompt/cache-relevant request shape after provider-specific +/// normalization, not the high-level Jcode message list. Do not include volatile transport +/// IDs unless they are intentionally part of the cache key. `items` should be the ordered +/// provider-visible message/content array so prefix drift can be diagnosed by index. +#[allow(clippy::too_many_arguments)] +pub fn log_provider_canonical_input( + provider: &str, + model: &str, + format: &str, + payload: &Value, + items: &[Value], + system: Option<&Value>, + tools: Option<&Value>, + tool_count: Option, + extra_fields: &[(&str, String)], +) { + let request_hash = stable_hash_json(payload); + let request_json_chars = stable_json_len(payload); + let item_hashes = item_hashes(items); + let item_hashes_hash = stable_hash_json(&item_hashes); + let input_hash = stable_hash_json(items); + let system_hash = system.map(stable_hash_json); + let system_json_chars = system.map(stable_json_len); + let tools_hash = tools.map(stable_hash_json); + let tools_json_chars = tools.map(stable_json_len); + let first_item_hash = item_hashes.first().copied(); + let last_item_hash = item_hashes.last().copied(); + + let log_context = jcode_logging::current_context_snapshot(); + let session_key = log_context.session.as_deref().unwrap_or("no-session"); + let key = format!( + "{}\u{1f}{}\u{1f}{}\u{1f}{}", + session_key, provider, model, format + ); + let snapshot = ProviderInputSnapshot { + request_hash, + item_hashes: item_hashes.clone(), + item_hashes_hash, + system_hash, + tools_hash, + captured_at: Instant::now(), + }; + + let previous = PROVIDER_INPUT_BASELINES + .lock() + .map(|mut baselines| baselines.insert(key, snapshot)) + .ok() + .flatten(); + + let previous_age_secs = previous + .as_ref() + .map(|previous| previous.captured_at.elapsed().as_secs()); + let request_changed = previous + .as_ref() + .map(|previous| previous.request_hash != request_hash); + let item_hashes_changed = previous + .as_ref() + .map(|previous| previous.item_hashes_hash != item_hashes_hash); + let prefix_matches = previous + .as_ref() + .map(|previous| prefix_matches(&item_hashes, &previous.item_hashes)); + let common_prefix_items = previous + .as_ref() + .map(|previous| common_prefix_len(&item_hashes, &previous.item_hashes)); + let first_changed_item_index = common_prefix_items + .zip(previous.as_ref().map(|previous| previous.item_hashes.len())) + .and_then(|(common, previous_len)| (common < previous_len).then_some(common)); + let previous_item_count = previous.as_ref().map(|previous| previous.item_hashes.len()); + let system_changed = previous + .as_ref() + .map(|previous| previous.system_hash != system_hash); + let tools_changed = previous + .as_ref() + .map(|previous| previous.tools_hash != tools_hash); + + let mut extras = String::new(); + for (key, value) in extra_fields { + if !key.is_empty() && !value.is_empty() { + extras.push(' '); + extras.push_str(key); + extras.push('='); + extras.push_str(value); + } + } + + jcode_logging::info(&format!( + "PROVIDER_CANONICAL_INPUT: provider={} model={} format={} request_hash={} request_json_chars={} \ + input_hash={} item_count={} previous_item_count={:?} item_hashes_hash={} first_item_hash={:?} last_item_hash={:?} \ + previous_age_secs={:?} prefix_matches={:?} common_prefix_items={:?} first_changed_item_index={:?} \ + request_changed={:?} item_hashes_changed={:?} system_hash={:?} system_json_chars={:?} system_changed={:?} \ + tools_hash={:?} tools_json_chars={:?} tool_count={:?} tools_changed={:?}{}", + provider, + model, + format, + request_hash, + request_json_chars, + input_hash, + items.len(), + previous_item_count, + item_hashes_hash, + first_item_hash, + last_item_hash, + previous_age_secs, + prefix_matches, + common_prefix_items, + first_changed_item_index, + request_changed, + item_hashes_changed, + system_hash, + system_json_chars, + system_changed, + tools_hash, + tools_json_chars, + tool_count, + tools_changed, + extras, + )); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn prefix_matching_allows_append_only_growth() { + assert!(prefix_matches(&[1, 2, 3], &[1, 2])); + } + + #[test] + fn prefix_matching_detects_changed_prefix() { + assert!(!prefix_matches(&[1, 9, 3], &[1, 2])); + assert_eq!(common_prefix_len(&[1, 9, 3], &[1, 2]), 1); + } + + #[test] + fn json_hashes_are_content_sensitive() { + assert_ne!( + stable_hash_json(&json!({"a": 1})), + stable_hash_json(&json!({"a": 2})) + ); + } +} diff --git a/crates/jcode-provider-core/src/lib.rs b/crates/jcode-provider-core/src/lib.rs index a0d59decb..18fa451fb 100644 --- a/crates/jcode-provider-core/src/lib.rs +++ b/crates/jcode-provider-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod anthropic; pub mod auth_mode; pub mod catalog_refresh; pub mod failover; +pub mod fingerprint; pub mod models; pub mod openai_schema; pub mod pricing; @@ -23,6 +24,7 @@ pub use failover::{ FailoverDecision, ProviderFailoverPrompt, classify_failover_error_message, parse_failover_prompt_message, }; +pub use fingerprint::{log_provider_canonical_input, stable_hash_json, stable_hash_str}; pub use models::{ ALL_CLAUDE_MODELS, ALL_OPENAI_MODELS, DEFAULT_CONTEXT_LIMIT, ModelCapabilities, context_limit_for_model, context_limit_for_model_with_provider, @@ -31,10 +33,10 @@ pub use models::{ provider_for_model_with_hint as core_provider_for_model_with_hint, provider_key_from_hint, }; pub use selection::{ - ActiveProvider, ProviderAvailability, auto_default_provider, - cli_provider_arg_for_session_key, dedupe_model_routes, explicit_model_provider_prefix, - fallback_sequence, model_name_for_provider, parse_provider_hint, provider_from_model_key, - provider_key, provider_label, + ActiveProvider, ProviderAvailability, auto_default_provider, cli_provider_arg_for_session_key, + dedupe_model_routes, explicit_model_provider_prefix, fallback_sequence, + model_name_for_provider, parse_provider_hint, provider_from_model_key, provider_key, + provider_label, }; use anyhow::Result; From de3f9db2c01a17f02fedea85a34b151c27fba147 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:05:07 -0700 Subject: [PATCH 31/41] refactor(provider): isolate bedrock provider crate --- Cargo.lock | 37 +- Cargo.toml | 1 + crates/jcode-base/Cargo.toml | 8 +- crates/jcode-base/src/provider/bedrock.rs | 1758 +-------------------- crates/jcode-provider-bedrock/Cargo.toml | 35 + crates/jcode-provider-bedrock/src/lib.rs | 1758 +++++++++++++++++++++ 6 files changed, 1826 insertions(+), 1771 deletions(-) create mode 100644 crates/jcode-provider-bedrock/Cargo.toml create mode 100644 crates/jcode-provider-bedrock/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 239aa1037..80d995076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3518,13 +3518,6 @@ dependencies = [ "agentgrep", "anyhow", "async-trait", - "aws-config", - "aws-credential-types", - "aws-sdk-bedrock", - "aws-sdk-bedrockruntime", - "aws-sdk-sts", - "aws-smithy-types", - "aws-types", "base64 0.22.1", "bytes", "chrono", @@ -3555,6 +3548,7 @@ dependencies = [ "jcode-message-types", "jcode-plan", "jcode-protocol", + "jcode-provider-bedrock", "jcode-provider-core", "jcode-provider-env", "jcode-provider-gemini", @@ -3828,6 +3822,35 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jcode-provider-bedrock" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "aws-config", + "aws-credential-types", + "aws-sdk-bedrock", + "aws-sdk-bedrockruntime", + "aws-sdk-sts", + "aws-smithy-types", + "aws-types", + "base64 0.22.1", + "chrono", + "futures", + "jcode-core", + "jcode-logging", + "jcode-message-types", + "jcode-provider-core", + "jcode-provider-env", + "jcode-storage", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-stream", +] + [[package]] name = "jcode-provider-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f3d4d94d3..875ff6412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "crates/jcode-provider-metadata", "crates/jcode-provider-env", "crates/jcode-provider-core", + "crates/jcode-provider-bedrock", "crates/jcode-provider-openrouter", "crates/jcode-provider-openai", "crates/jcode-provider-gemini", diff --git a/crates/jcode-base/Cargo.toml b/crates/jcode-base/Cargo.toml index 27de52002..47224e1e8 100644 --- a/crates/jcode-base/Cargo.toml +++ b/crates/jcode-base/Cargo.toml @@ -71,6 +71,7 @@ jcode-agent-runtime = { path = "../jcode-agent-runtime" } jcode-provider-metadata = { path = "../jcode-provider-metadata" } jcode-provider-env = { path = "../jcode-provider-env" } jcode-provider-core = { path = "../jcode-provider-core" } +jcode-provider-bedrock = { path = "../jcode-provider-bedrock" } jcode-provider-openai = { path = "../jcode-provider-openai" } jcode-provider-openrouter = { path = "../jcode-provider-openrouter" } jcode-provider-gemini = { path = "../jcode-provider-gemini" } @@ -119,13 +120,6 @@ flate2 = "1" tempfile = "3" agentgrep = { git = "https://github.com/1jehuang/agentgrep.git", tag = "v0.1.2" } qrcode = { version = "0.14.1", default-features = false } -aws-config = "1.8.16" -aws-credential-types = "1.2.14" -aws-sdk-bedrockruntime = "1.130.0" -aws-types = "1.3.15" -aws-smithy-types = "1.4.7" -aws-sdk-bedrock = "1.141.0" -aws-sdk-sts = "1.103.0" [features] default = ["embeddings"] diff --git a/crates/jcode-base/src/provider/bedrock.rs b/crates/jcode-base/src/provider/bedrock.rs index cfdd524f4..712f7768d 100644 --- a/crates/jcode-base/src/provider/bedrock.rs +++ b/crates/jcode-base/src/provider/bedrock.rs @@ -1,1757 +1 @@ -use super::{ - DEFAULT_CONTEXT_LIMIT, EventStream, ModelCatalogRefreshSummary, ModelRoute, Provider, - RouteCheapnessEstimate, RouteCostConfidence, RouteCostSource, summarize_model_catalog_refresh, -}; -use crate::message::{ - ContentBlock as JContentBlock, Message as JMessage, Role as JRole, StreamEvent, ToolDefinition, -}; -use anyhow::{Context, Result}; -use async_trait::async_trait; -use aws_config::BehaviorVersion; -use aws_credential_types::Credentials; -use aws_sdk_bedrock::Client as BedrockControlClient; -use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient; -use aws_sdk_bedrockruntime::types::{ - ContentBlock, ContentBlockDelta, ContentBlockStart, ConversationRole, ConverseStreamOutput, - ImageBlock, ImageFormat, ImageSource, InferenceConfiguration, Message, - ReasoningContentBlockDelta, SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, - ToolSpecification, -}; -use aws_smithy_types::Blob; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use std::collections::{HashMap, HashSet}; -use std::pin::Pin; -use std::sync::{Arc, RwLock}; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; - -const DEFAULT_MODEL: &str = "anthropic.claude-3-5-sonnet-20241022-v2:0"; -const DEFAULT_MAX_OUTPUT_TOKENS: usize = 4096; -pub const ENV_FILE: &str = "bedrock.env"; -pub const API_KEY_ENV: &str = "AWS_BEARER_TOKEN_BEDROCK"; -pub const REGION_ENV: &str = "JCODE_BEDROCK_REGION"; - -#[derive(Debug, Clone)] -struct BedrockModelInfo { - context_tokens: usize, - max_output_tokens: usize, - supports_tools: bool, - supports_vision: bool, - supports_reasoning: bool, - pricing: Option<(u64, u64)>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct PersistedCatalog { - models: Vec, - inference_profiles: Vec, - #[serde(default)] - profile_required_models: Vec, - #[serde(default)] - inference_profile_routes: HashMap, - #[serde(default)] - legacy_models: Vec, - region: Option, - fetched_at_rfc3339: String, -} - -pub struct BedrockProvider { - model: Arc>, - fetched_models: Arc>>, - fetched_inference_profiles: Arc>>, - profile_required_models: Arc>>, - inference_profile_routes: Arc>>, - legacy_models: Arc>>, -} - -impl BedrockProvider { - pub fn new() -> Self { - let model = - std::env::var("JCODE_BEDROCK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); - let provider = Self { - model: Arc::new(RwLock::new(model)), - fetched_models: Arc::new(RwLock::new(Vec::new())), - fetched_inference_profiles: Arc::new(RwLock::new(Vec::new())), - profile_required_models: Arc::new(RwLock::new(HashSet::new())), - inference_profile_routes: Arc::new(RwLock::new(HashMap::new())), - legacy_models: Arc::new(RwLock::new(HashSet::new())), - }; - provider.seed_cached_catalog(); - provider - } - - pub fn has_credentials() -> bool { - let explicitly_enabled = std::env::var("JCODE_BEDROCK_ENABLE") - .ok() - .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) - .unwrap_or(false); - if explicitly_enabled { - return true; - } - - let has_region = Self::configured_region().is_some(); - let has_credential_hint = Self::configured_bearer_token().is_some() - || std::env::var_os("AWS_ACCESS_KEY_ID").is_some() - || std::env::var_os("AWS_PROFILE").is_some() - || std::env::var_os("JCODE_BEDROCK_PROFILE").is_some() - || std::env::var_os("AWS_WEB_IDENTITY_TOKEN_FILE").is_some() - || std::env::var_os("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some() - || std::env::var_os("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some() - || std::env::var_os("AWS_SHARED_CREDENTIALS_FILE").is_some() - || std::env::var_os("AWS_CONFIG_FILE").is_some(); - - has_region && has_credential_hint - } - - async fn sdk_config() -> aws_types::SdkConfig { - let mut loader = aws_config::defaults(BehaviorVersion::latest()); - if let Some(token) = Self::configured_bearer_token() { - crate::env::set_var(API_KEY_ENV, token); - } - if let Some(region) = Self::configured_region() { - loader = loader.region(aws_types::region::Region::new(region)); - } - if let Ok(profile) = - std::env::var("JCODE_BEDROCK_PROFILE").or_else(|_| std::env::var("AWS_PROFILE")) - { - if let Some(credentials) = Self::credentials_from_aws_login_profile(&profile).await { - loader = loader.credentials_provider(credentials); - } - loader = loader.profile_name(profile); - } - loader.load().await - } - - async fn credentials_from_aws_login_profile(profile: &str) -> Option { - if std::env::var_os("AWS_ACCESS_KEY_ID").is_some() - || std::env::var_os("AWS_SECRET_ACCESS_KEY").is_some() - || std::env::var_os("AWS_BEARER_TOKEN_BEDROCK").is_some() - { - return None; - } - - let output = tokio::process::Command::new("aws") - .args([ - "configure", - "export-credentials", - "--profile", - profile, - "--format", - "env-no-export", - ]) - .output() - .await - .ok()?; - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8(output.stdout).ok()?; - let mut access_key_id = None; - let mut secret_access_key = None; - let mut session_token = None; - for line in stdout.lines() { - let Some((key, value)) = line.split_once('=') else { - continue; - }; - match key.trim() { - "AWS_ACCESS_KEY_ID" => access_key_id = Some(value.trim().to_string()), - "AWS_SECRET_ACCESS_KEY" => secret_access_key = Some(value.trim().to_string()), - "AWS_SESSION_TOKEN" => session_token = Some(value.trim().to_string()), - _ => {} - } - } - - Some(Credentials::new( - access_key_id?, - secret_access_key?, - session_token, - None, - "aws-cli-export-credentials", - )) - } - - async fn runtime_client() -> BedrockRuntimeClient { - let config = Self::sdk_config().await; - BedrockRuntimeClient::new(&config) - } - - async fn control_client() -> BedrockControlClient { - let config = Self::sdk_config().await; - BedrockControlClient::new(&config) - } - - async fn validate_credentials_if_requested() -> Result<()> { - let validate = std::env::var("JCODE_BEDROCK_VALIDATE_STS") - .ok() - .map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no")) - .unwrap_or(false); - if !validate { - return Ok(()); - } - let config = Self::sdk_config().await; - let client = aws_sdk_sts::Client::new(&config); - client - .get_caller_identity() - .send() - .await - .map(|_| ()) - .map_err(|err| { - anyhow::anyhow!(Self::classify_error_message(&Self::sdk_error_message(&err))) - }) - } - - fn configured_region() -> Option { - Self::env_or_config(REGION_ENV) - .or_else(|| Self::env_or_config("AWS_REGION")) - .or_else(|| Self::env_or_config("AWS_DEFAULT_REGION")) - } - - pub fn configured_bearer_token() -> Option { - crate::provider_catalog::load_api_key_from_env_or_config(API_KEY_ENV, ENV_FILE) - } - - fn env_or_config(name: &str) -> Option { - std::env::var(name) - .ok() - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .or_else(|| crate::provider_catalog::load_env_value_from_env_or_config(name, ENV_FILE)) - } - - fn persisted_catalog_path() -> Result { - Ok(crate::storage::app_config_dir()?.join("bedrock_models_cache.json")) - } - - fn load_persisted_catalog() -> Option { - let path = Self::persisted_catalog_path().ok()?; - crate::storage::read_json(&path).ok() - } - - fn persist_catalog( - models: &[String], - inference_profiles: &[String], - profile_required_models: &HashSet, - inference_profile_routes: &HashMap, - legacy_models: &HashSet, - ) { - let Ok(path) = Self::persisted_catalog_path() else { - return; - }; - let payload = PersistedCatalog { - models: models.to_vec(), - inference_profiles: inference_profiles.to_vec(), - profile_required_models: profile_required_models.iter().cloned().collect(), - inference_profile_routes: inference_profile_routes.clone(), - legacy_models: legacy_models.iter().cloned().collect(), - region: Self::configured_region(), - fetched_at_rfc3339: chrono::Utc::now().to_rfc3339(), - }; - if let Err(err) = crate::storage::write_json(&path, &payload) { - crate::logging::warn(&format!( - "Failed to persist Bedrock model catalog {}: {}", - path.display(), - err - )); - } - } - - fn seed_cached_catalog(&self) { - if let Some(catalog) = Self::load_persisted_catalog() { - let configured_region = Self::configured_region(); - if catalog.region.as_deref() != configured_region.as_deref() { - crate::logging::info(&format!( - "Ignoring Bedrock model cache for region {:?}; configured region is {:?}", - catalog.region, configured_region - )); - return; - } - let PersistedCatalog { - models: cached_models, - inference_profiles, - profile_required_models, - inference_profile_routes, - legacy_models, - .. - } = catalog; - let mut inference_profile_routes = inference_profile_routes; - Self::merge_profile_routes_from_profile_ids( - &mut inference_profile_routes, - inference_profiles.iter(), - ); - if let Ok(mut guard) = self.fetched_models.write() { - *guard = cached_models; - } - if let Ok(mut profiles) = self.fetched_inference_profiles.write() { - *profiles = inference_profiles; - } - if let Ok(mut required) = self.profile_required_models.write() { - *required = profile_required_models.into_iter().collect(); - } - if let Ok(mut routes) = self.inference_profile_routes.write() { - *routes = inference_profile_routes; - } - if let Ok(mut legacy) = self.legacy_models.write() { - *legacy = legacy_models.into_iter().collect(); - } - } - } - - fn classify_error_message(raw: &str) -> String { - let lower = raw.to_ascii_lowercase(); - let is_legacy_model_error = lower.contains("marked by provider as legacy") - || lower.contains("model is marked") && lower.contains("legacy") - || lower.contains("have not been actively using the model in the last 30 days"); - if is_legacy_model_error { - return format!( - "{} Original error: {}", - "This Bedrock model is marked as legacy for this account. Choose an active Bedrock model or an active inference profile instead.", - raw.trim() - ); - } else if lower.contains("doesn't support tool use") - || lower.contains("does not support tool use") - || lower.contains("tool use in streaming mode") - { - return format!( - "{} Original error: {}", - "This Bedrock model does not support tool use with streaming. Choose a Bedrock model with tool support, such as a Claude or Nova profile, or use a no-tools Bedrock model route.", - raw.trim() - ); - } else if lower.contains("no credentials") - || lower.contains("could not load credentials") - || lower.contains("credentials") && lower.contains("not loaded") - { - return "AWS credentials were not found. Set AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or run `aws sso login`.".to_string(); - } else if lower.contains("expired") || lower.contains("sso") && lower.contains("token") { - return "AWS SSO/session credentials look expired. Run `aws sso login --profile ` and retry.".to_string(); - } - - let hint = if lower.contains("accessdenied") - || lower.contains("access denied") - || lower.contains("not authorized") - { - "AWS IAM denied the Bedrock request. Ensure the principal can call bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream, bedrock:ListFoundationModels, and bedrock:ListInferenceProfiles as needed." - } else if lower.contains("validationexception") && lower.contains("model") - || lower.contains("model") && lower.contains("not found") - || lower.contains("resource not found") - { - "Bedrock did not recognize this model in the selected region/account. Check model ID, inference profile ID, region, and model access." - } else if lower.contains("throttl") - || lower.contains("too many requests") - || lower.contains("rate exceeded") - { - "Bedrock throttled the request. Retry later or request a quota increase." - } else if lower.contains("region") && lower.contains("missing") { - "AWS region is missing. Set AWS_REGION or JCODE_BEDROCK_REGION." - } else { - "Bedrock request failed. Check AWS credentials, region, model access, and IAM permissions." - }; - format!("{} Original error: {}", hint, raw.trim()) - } - - fn sdk_error_message(err: &(impl std::fmt::Display + std::fmt::Debug)) -> String { - let display = err.to_string(); - let trimmed = display.trim(); - if trimmed.is_empty() - || trimmed.eq_ignore_ascii_case("service error") - || trimmed.eq_ignore_ascii_case("dispatch failure") - { - format!("{err:?}") - } else { - display - } - } - - fn json_to_document(value: &serde_json::Value) -> aws_smithy_types::Document { - match value { - serde_json::Value::Null => aws_smithy_types::Document::Null, - serde_json::Value::Bool(v) => aws_smithy_types::Document::Bool(*v), - serde_json::Value::Number(n) => { - if let Some(v) = n.as_u64() { - aws_smithy_types::Document::from(v) - } else if let Some(v) = n.as_i64() { - aws_smithy_types::Document::from(v) - } else if let Some(v) = n.as_f64() { - aws_smithy_types::Document::from(v) - } else { - aws_smithy_types::Document::Null - } - } - serde_json::Value::String(v) => aws_smithy_types::Document::String(v.clone()), - serde_json::Value::Array(values) => aws_smithy_types::Document::Array( - values.iter().map(Self::json_to_document).collect(), - ), - serde_json::Value::Object(map) => aws_smithy_types::Document::Object( - map.iter() - .map(|(key, value)| (key.clone(), Self::json_to_document(value))) - .collect::>(), - ), - } - } - - fn image_format_for_media_type(media_type: &str) -> Option { - match media_type.trim().to_ascii_lowercase().as_str() { - "image/png" => Some(ImageFormat::Png), - "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), - "image/gif" => Some(ImageFormat::Gif), - "image/webp" => Some(ImageFormat::Webp), - _ => None, - } - } - - fn image_block(media_type: &str, data: &str) -> Result { - let format = Self::image_format_for_media_type(media_type).ok_or_else(|| { - anyhow::anyhow!( - "Bedrock image input does not support media type `{}`", - media_type - ) - })?; - let bytes = BASE64.decode(data).with_context(|| { - format!("Failed to decode {} image payload for Bedrock", media_type) - })?; - ImageBlock::builder() - .format(format) - .source(ImageSource::Bytes(Blob::new(bytes))) - .build() - .context("Failed to build Bedrock image block") - } - - fn to_bedrock_messages(messages: &[JMessage], allow_images: bool) -> Result> { - messages - .iter() - .filter_map(|msg| { - let role = match msg.role { - JRole::User => ConversationRole::User, - JRole::Assistant => ConversationRole::Assistant, - }; - let mut content = Vec::new(); - for block in &msg.content { - match block { - JContentBlock::Text { text, .. } => { - content.push(ContentBlock::Text(text.clone())) - } - JContentBlock::Image { media_type, data } => { - if !allow_images { - return Some(Err(anyhow::anyhow!( - "Current Bedrock model does not advertise image input support" - ))); - } - match Self::image_block(media_type, data) { - Ok(image) => content.push(ContentBlock::Image(image)), - Err(err) => return Some(Err(err)), - } - } - JContentBlock::ToolResult { - tool_use_id, - content: text, - is_error, - } => { - let status = if is_error.unwrap_or(false) { - aws_sdk_bedrockruntime::types::ToolResultStatus::Error - } else { - aws_sdk_bedrockruntime::types::ToolResultStatus::Success - }; - let result = - match aws_sdk_bedrockruntime::types::ToolResultBlock::builder() - .tool_use_id(tool_use_id) - .status(status) - .content( - aws_sdk_bedrockruntime::types::ToolResultContentBlock::Text( - text.clone(), - ), - ) - .build() - { - Ok(result) => result, - Err(err) => return Some(Err(anyhow::anyhow!(err))), - }; - content.push(ContentBlock::ToolResult(result)); - } - JContentBlock::ToolUse { - id, name, input, .. - } => { - let tool_use = - match aws_sdk_bedrockruntime::types::ToolUseBlock::builder() - .tool_use_id(id) - .name(name) - .input(Self::json_to_document(input)) - .build() - { - Ok(tool_use) => tool_use, - Err(err) => return Some(Err(anyhow::anyhow!(err))), - }; - content.push(ContentBlock::ToolUse(tool_use)); - } - _ => {} - } - } - if content.is_empty() { - return None; - } - Some( - Message::builder() - .role(role) - .set_content(Some(content)) - .build() - .map_err(|err| anyhow::anyhow!(err)), - ) - }) - .collect() - } - - fn tool_config(tools: &[ToolDefinition]) -> Option { - if tools.is_empty() { - return None; - } - let bedrock_tools = tools - .iter() - .filter_map(|tool| { - let schema = ToolInputSchema::Json(Self::json_to_document(&tool.input_schema)); - ToolSpecification::builder() - .name(&tool.name) - .description(tool.description.clone()) - .input_schema(schema) - .build() - .ok() - .map(Tool::ToolSpec) - }) - .collect::>(); - if bedrock_tools.is_empty() { - None - } else { - ToolConfiguration::builder() - .set_tools(Some(bedrock_tools)) - .build() - .ok() - } - } - - fn inference_config() -> Option { - let max_tokens = std::env::var("JCODE_BEDROCK_MAX_TOKENS") - .ok() - .and_then(|v| v.trim().parse::().ok()) - .filter(|v| *v > 0); - let temperature = std::env::var("JCODE_BEDROCK_TEMPERATURE") - .ok() - .and_then(|v| v.trim().parse::().ok()) - .filter(|v| (0.0..=1.0).contains(v)); - let top_p = std::env::var("JCODE_BEDROCK_TOP_P") - .ok() - .and_then(|v| v.trim().parse::().ok()) - .filter(|v| (0.0..=1.0).contains(v)); - let stop_sequences = std::env::var("JCODE_BEDROCK_STOP_SEQUENCES") - .ok() - .map(|v| { - v.split(',') - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(str::to_string) - .collect::>() - }) - .filter(|v| !v.is_empty()); - if max_tokens.is_none() - && temperature.is_none() - && top_p.is_none() - && stop_sequences.is_none() - { - return None; - } - Some( - InferenceConfiguration::builder() - .set_max_tokens(max_tokens) - .set_temperature(temperature) - .set_top_p(top_p) - .set_stop_sequences(stop_sequences) - .build(), - ) - } - - fn normalize_model_id(model: &str) -> String { - let mut value = model.trim().to_string(); - if let Some((_, tail)) = value.rsplit_once('/') { - value = tail.to_string(); - } - for prefix in ["us.", "eu.", "apac.", "global."] { - if let Some(stripped) = value.strip_prefix(prefix) { - value = stripped.to_string(); - break; - } - } - value - } - - fn foundation_model_id_from_arn(arn: &str) -> Option { - arn.rsplit_once("foundation-model/") - .map(|(_, model)| model.trim()) - .filter(|model| !model.is_empty()) - .map(str::to_string) - } - - fn inference_profile_id_from_arn(arn: &str) -> Option { - arn.rsplit_once("inference-profile/") - .map(|(_, profile)| profile.trim()) - .filter(|profile| !profile.is_empty()) - .map(str::to_string) - } - - fn foundation_model_id_from_profile_id(profile_id: &str) -> Option { - let id = profile_id.trim(); - let id = Self::inference_profile_id_from_arn(id).unwrap_or_else(|| id.to_string()); - for prefix in ["us.", "eu.", "apac.", "global."] { - if let Some(model) = id.strip_prefix(prefix) - && !model.is_empty() - { - return Some(model.to_string()); - } - } - None - } - - fn region_profile_prefix() -> Option<&'static str> { - let region = Self::configured_region()?; - if region.starts_with("us-") { - Some("us.") - } else if region.starts_with("eu-") { - Some("eu.") - } else if region.starts_with("ap-") { - Some("apac.") - } else { - None - } - } - - fn inference_profile_priority(profile_id: &str) -> u8 { - let id = profile_id.trim().to_ascii_lowercase(); - if let Some(prefix) = Self::region_profile_prefix() - && id.starts_with(prefix) - { - return 0; - } - if id.starts_with("us.") || id.starts_with("eu.") || id.starts_with("apac.") { - 1 - } else if id.starts_with("global.") { - 2 - } else { - 3 - } - } - - fn insert_preferred_profile_route( - routes: &mut HashMap, - foundation_model: &str, - profile_id: &str, - ) { - let foundation_model = foundation_model.trim(); - let profile_id = profile_id.trim(); - if foundation_model.is_empty() || profile_id.is_empty() { - return; - } - let should_replace = routes - .get(foundation_model) - .map(|current| { - Self::inference_profile_priority(profile_id) - < Self::inference_profile_priority(current) - }) - .unwrap_or(true); - if should_replace { - routes.insert(foundation_model.to_string(), profile_id.to_string()); - } - } - - fn merge_profile_routes_from_profile_ids( - routes: &mut HashMap, - profiles: impl IntoIterator>, - ) { - for profile in profiles { - let profile = profile.as_ref().trim(); - let Some(foundation_model) = Self::foundation_model_id_from_profile_id(profile) else { - continue; - }; - let profile_id = - Self::inference_profile_id_from_arn(profile).unwrap_or_else(|| profile.to_string()); - Self::insert_preferred_profile_route(routes, &foundation_model, &profile_id); - } - } - - fn profile_route_for_model(&self, model: &str) -> Option { - let model = model.trim(); - if model.is_empty() { - return None; - } - - if let Ok(routes) = self.inference_profile_routes.read() - && let Some(route) = routes.get(model).cloned() - { - return Some(route); - } - - if let Ok(profiles) = self.fetched_inference_profiles.read() { - let mut derived = HashMap::new(); - Self::merge_profile_routes_from_profile_ids(&mut derived, profiles.iter()); - if let Some(route) = derived.get(model).cloned() { - return Some(route); - } - } - - None - } - - pub fn is_bedrock_model_id(model: &str) -> bool { - let trimmed = model.trim(); - if trimmed.is_empty() { - return false; - } - if trimmed.starts_with("arn:aws:bedrock:") { - return true; - } - - let id = Self::normalize_model_id(trimmed).to_ascii_lowercase(); - id.starts_with("anthropic.") - || id.starts_with("amazon.") - || id.starts_with("cohere.") - || id.starts_with("ai21.") - || id.starts_with("meta.") - || id.starts_with("mistral.") - || id.starts_with("stability.") - || id.starts_with("writer.") - || id.starts_with("deepseek.") - || id.starts_with("openai.") - || id.starts_with("qwen.") - || id.starts_with("moonshot.") - || id.starts_with("moonshotai.") - || id.starts_with("minimax.") - || id.starts_with("zai.") - || id.starts_with("google.") - || id.starts_with("nvidia.") - } - - fn model_info(model: &str) -> BedrockModelInfo { - let id = Self::normalize_model_id(model).to_ascii_lowercase(); - if id.contains("claude-opus-4") || id.contains("claude-sonnet-4") { - BedrockModelInfo { - context_tokens: 200_000, - max_output_tokens: 64_000, - supports_tools: true, - supports_vision: true, - supports_reasoning: true, - pricing: Some((3_000_000, 15_000_000)), - } - } else if id.contains("claude-3-7-sonnet") || id.contains("claude-3-5-sonnet") { - BedrockModelInfo { - context_tokens: 200_000, - max_output_tokens: 8_192, - supports_tools: true, - supports_vision: true, - supports_reasoning: id.contains("3-7"), - pricing: Some((3_000_000, 15_000_000)), - } - } else if id.contains("claude-3-5-haiku") || id.contains("claude-3-haiku") { - BedrockModelInfo { - context_tokens: 200_000, - max_output_tokens: 8_192, - supports_tools: true, - supports_vision: true, - supports_reasoning: false, - pricing: Some((800_000, 4_000_000)), - } - } else if id.contains("amazon.nova-pro") { - BedrockModelInfo { - context_tokens: 300_000, - max_output_tokens: 5_120, - supports_tools: true, - supports_vision: true, - supports_reasoning: false, - pricing: Some((800_000, 3_200_000)), - } - } else if id.contains("amazon.nova-2-lite") || id.contains("amazon.nova-lite") { - BedrockModelInfo { - context_tokens: 300_000, - max_output_tokens: 5_120, - supports_tools: true, - supports_vision: true, - supports_reasoning: false, - pricing: Some((60_000, 240_000)), - } - } else if id.contains("amazon.nova-micro") { - BedrockModelInfo { - context_tokens: 128_000, - max_output_tokens: 5_120, - supports_tools: true, - supports_vision: false, - supports_reasoning: false, - pricing: Some((35_000, 140_000)), - } - } else if id.starts_with("deepseek.") { - BedrockModelInfo { - context_tokens: 128_000, - max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, - supports_tools: false, - supports_vision: false, - supports_reasoning: true, - pricing: None, - } - } else if id.contains("llama3-1-405b") || id.starts_with("meta.") { - BedrockModelInfo { - context_tokens: 128_000, - max_output_tokens: 4_096, - supports_tools: false, - supports_vision: false, - supports_reasoning: false, - pricing: Some((5_320_000, 16_000_000)), - } - } else if id.starts_with("mistral.") { - BedrockModelInfo { - context_tokens: 128_000, - max_output_tokens: 8_192, - supports_tools: false, - supports_vision: false, - supports_reasoning: false, - pricing: Some((4_000_000, 12_000_000)), - } - } else if id.starts_with("openai.") - || id.starts_with("qwen.") - || id.starts_with("moonshot.") - || id.starts_with("moonshotai.") - || id.starts_with("minimax.") - || id.starts_with("zai.") - || id.starts_with("google.") - || id.starts_with("nvidia.") - || id.starts_with("writer.") - { - BedrockModelInfo { - context_tokens: DEFAULT_CONTEXT_LIMIT, - max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, - supports_tools: false, - supports_vision: false, - supports_reasoning: id.contains("thinking") - || id.contains("reason") - || id.contains("gpt-oss"), - pricing: None, - } - } else { - BedrockModelInfo { - context_tokens: DEFAULT_CONTEXT_LIMIT, - max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, - supports_tools: false, - supports_vision: false, - supports_reasoning: false, - pricing: None, - } - } - } - - fn route_pricing(model: &str) -> Option { - let info = Self::model_info(model); - info.pricing.map(|(input, output)| { - RouteCheapnessEstimate::metered( - RouteCostSource::Heuristic, - RouteCostConfidence::Medium, - input, - output, - None, - Some("AWS Bedrock public on-demand pricing heuristic; verify for your region/account".to_string()), - ) - }) - } - - fn known_models() -> Vec<&'static str> { - vec![ - "anthropic.claude-3-5-sonnet-20241022-v2:0", - "anthropic.claude-3-5-haiku-20241022-v1:0", - "anthropic.claude-3-7-sonnet-20250219-v1:0", - "anthropic.claude-sonnet-4-20250514-v1:0", - "anthropic.claude-opus-4-20250514-v1:0", - "amazon.nova-pro-v1:0", - "amazon.nova-lite-v1:0", - "amazon.nova-micro-v1:0", - "meta.llama3-1-405b-instruct-v1:0", - "mistral.mistral-large-2407-v1:0", - ] - } - - fn all_display_models(&self) -> Vec { - let mut seen = HashSet::new(); - let mut models = Vec::new(); - let inference_profile_routes = self - .inference_profile_routes - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); - let should_hide_duplicate_foundation_model = - |model: &str| inference_profile_routes.contains_key(model); - for model in Self::known_models().into_iter().map(str::to_string) { - if should_hide_duplicate_foundation_model(&model) { - continue; - } - if seen.insert(model.clone()) { - models.push(model); - } - } - if let Ok(fetched) = self.fetched_models.read() { - for model in fetched.iter() { - if should_hide_duplicate_foundation_model(model) { - continue; - } - if seen.insert(model.clone()) { - models.push(model.clone()); - } - } - } - if let Ok(profiles) = self.fetched_inference_profiles.read() { - for profile in profiles.iter() { - if seen.insert(profile.clone()) { - models.push(profile.clone()); - } - } - } - models - } - - async fn refresh_catalog(&self) -> Result<(Vec, Vec)> { - let client = Self::control_client().await; - let mut models = Vec::new(); - let mut profile_required_models = HashSet::new(); - let mut legacy_models = HashSet::new(); - let model_resp = client - .list_foundation_models() - .send() - .await - .map_err(|err| { - anyhow::anyhow!(Self::classify_error_message(&Self::sdk_error_message(&err))) - })?; - for summary in model_resp.model_summaries() { - let model_id = summary.model_id(); - if !model_id.is_empty() { - models.push(model_id.to_string()); - let inference_types = summary.inference_types_supported(); - let supports_on_demand = inference_types - .iter() - .any(|kind| kind.as_str() == "ON_DEMAND"); - let supports_inference_profile = inference_types - .iter() - .any(|kind| kind.as_str() == "INFERENCE_PROFILE"); - if supports_inference_profile && !supports_on_demand { - profile_required_models.insert(model_id.to_string()); - } - if summary - .model_lifecycle() - .map(|lifecycle| lifecycle.status().as_str() == "LEGACY") - .unwrap_or(false) - { - legacy_models.insert(model_id.to_string()); - } - } - } - models.sort(); - models.dedup(); - - let mut profiles = Vec::new(); - let mut inference_profile_routes = HashMap::new(); - match client.list_inference_profiles().send().await { - Ok(resp) => { - for summary in resp.inference_profile_summaries() { - let id = summary.inference_profile_id(); - if !id.is_empty() { - profiles.push(id.to_string()); - } - let arn = summary.inference_profile_arn(); - if !arn.is_empty() { - profiles.push(arn.to_string()); - } - if summary.status().as_str() == "ACTIVE" && !id.is_empty() { - for model in summary.models() { - if let Some(model_arn) = model.model_arn() - && let Some(foundation_model) = - Self::foundation_model_id_from_arn(model_arn) - { - Self::insert_preferred_profile_route( - &mut inference_profile_routes, - &foundation_model, - id, - ); - } - } - } - } - profiles.sort(); - profiles.dedup(); - Self::merge_profile_routes_from_profile_ids( - &mut inference_profile_routes, - profiles.iter(), - ); - } - Err(err) => { - crate::logging::info(&format!( - "Bedrock inference profile discovery skipped: {}", - Self::classify_error_message(&Self::sdk_error_message(&err)) - )); - } - } - - if let Ok(mut guard) = self.fetched_models.write() { - *guard = models.clone(); - } - if let Ok(mut guard) = self.fetched_inference_profiles.write() { - *guard = profiles.clone(); - } - if let Ok(mut guard) = self.profile_required_models.write() { - *guard = profile_required_models.clone(); - } - if let Ok(mut guard) = self.inference_profile_routes.write() { - *guard = inference_profile_routes.clone(); - } - if let Ok(mut guard) = self.legacy_models.write() { - *guard = legacy_models.clone(); - } - Self::persist_catalog( - &models, - &profiles, - &profile_required_models, - &inference_profile_routes, - &legacy_models, - ); - Ok((models, profiles)) - } -} - -impl Default for BedrockProvider { - fn default() -> Self { - Self::new() - } -} - -#[async_trait] -impl Provider for BedrockProvider { - async fn complete( - &self, - messages: &[JMessage], - tools: &[ToolDefinition], - system: &str, - _resume_session_id: Option<&str>, - ) -> Result { - Self::validate_credentials_if_requested().await?; - let model = self.model(); - let info = Self::model_info(&model); - let request_messages = Self::to_bedrock_messages(messages, info.supports_vision)?; - let tool_config = if info.supports_tools { - Self::tool_config(tools) - } else { - None - }; - let inference_config = Self::inference_config(); - let system_blocks = if system.trim().is_empty() { - None - } else { - Some(vec![SystemContentBlock::Text(system.to_string())]) - }; - let message_items = serde_json::to_value(messages) - .ok() - .and_then(|value| value.as_array().cloned()) - .unwrap_or_default(); - let system_value = (!system.trim().is_empty()).then(|| Value::String(system.to_string())); - let tools_value = if info.supports_tools && !tools.is_empty() { - serde_json::to_value(tools).ok() - } else { - None - }; - let payload = json!({ - "model": &model, - "system": system_value.as_ref(), - "messages": &message_items, - "tools": tools_value.as_ref(), - "supports_tools": info.supports_tools, - "supports_vision": info.supports_vision, - "inference_config_present": inference_config.is_some(), - }); - super::fingerprint::log_provider_canonical_input( - "bedrock", - &model, - "bedrock_converse_logical", - &payload, - &message_items, - system_value.as_ref(), - tools_value.as_ref(), - Some(if info.supports_tools { tools.len() } else { 0 }), - &[ - ("supports_tools", info.supports_tools.to_string()), - ("supports_vision", info.supports_vision.to_string()), - ( - "inference_config_present", - inference_config.is_some().to_string(), - ), - ], - ); - let (tx, rx) = mpsc::channel::>(64); - tokio::spawn(async move { - let client = Self::runtime_client().await; - let mut req = client - .converse_stream() - .model_id(model.clone()) - .set_messages(Some(request_messages)); - if let Some(system_blocks) = system_blocks { - req = req.set_system(Some(system_blocks)); - } - if let Some(tool_config) = tool_config { - req = req.tool_config(tool_config); - } - if let Some(inference_config) = inference_config { - req = req.inference_config(inference_config); - } - let resp = match req.send().await { - Ok(resp) => resp, - Err(err) => { - let _ = tx - .send(Err(anyhow::anyhow!(Self::classify_error_message( - &Self::sdk_error_message(&err) - )))) - .await; - return; - } - }; - let mut stream = resp.stream; - let mut current_tool: Option<(String, String, String)> = None; - loop { - match stream.recv().await { - Ok(Some(event)) => match event { - ConverseStreamOutput::ContentBlockStart(start) => { - if let Some(ContentBlockStart::ToolUse(tool)) = start.start { - let id = tool.tool_use_id().to_string(); - let name = tool.name().to_string(); - current_tool = Some((id.clone(), name.clone(), String::new())); - let _ = tx.send(Ok(StreamEvent::ToolUseStart { id, name })).await; - } - } - ConverseStreamOutput::ContentBlockDelta(delta) => { - if let Some(d) = delta.delta { - match d { - ContentBlockDelta::Text(text) => { - let _ = tx.send(Ok(StreamEvent::TextDelta(text))).await; - } - ContentBlockDelta::ToolUse(tool_delta) => { - let input = tool_delta.input(); - if !input.is_empty() { - if let Some((_, _, buf)) = current_tool.as_mut() { - buf.push_str(input); - } - let _ = tx - .send(Ok(StreamEvent::ToolInputDelta( - input.to_string(), - ))) - .await; - } - } - ContentBlockDelta::ReasoningContent(reasoning) => { - if let ReasoningContentBlockDelta::Text(text) = reasoning { - let _ = - tx.send(Ok(StreamEvent::ThinkingDelta(text))).await; - } - } - _ => {} - } - } - } - ConverseStreamOutput::ContentBlockStop(_) => { - if current_tool.take().is_some() { - let _ = tx.send(Ok(StreamEvent::ToolUseEnd)).await; - } - } - ConverseStreamOutput::MessageStop(stop) => { - let reason = Some(format!("{:?}", stop.stop_reason())); - let _ = tx - .send(Ok(StreamEvent::MessageEnd { - stop_reason: reason, - })) - .await; - } - ConverseStreamOutput::Metadata(meta) => { - if let Some(usage) = meta.usage() { - let _ = tx - .send(Ok(StreamEvent::TokenUsage { - input_tokens: Some(usage.input_tokens() as u64), - output_tokens: Some(usage.output_tokens() as u64), - cache_read_input_tokens: None, - cache_creation_input_tokens: None, - })) - .await; - } - } - _ => {} - }, - Ok(None) => break, - Err(err) => { - let _ = tx - .send(Err(anyhow::anyhow!(Self::classify_error_message( - &Self::sdk_error_message(&err) - )))) - .await; - break; - } - } - } - }); - Ok(Box::pin(ReceiverStream::new(rx)) - as Pin< - Box> + Send>, - >) - } - - fn name(&self) -> &str { - "bedrock" - } - - fn model(&self) -> String { - self.model.read().unwrap_or_else(|p| p.into_inner()).clone() - } - - fn supports_image_input(&self) -> bool { - Self::model_info(&self.model()).supports_vision - } - - fn set_model(&self, model: &str) -> Result<()> { - let model = model.trim(); - let model = self - .profile_route_for_model(model) - .unwrap_or_else(|| model.to_string()); - *self.model.write().unwrap_or_else(|p| p.into_inner()) = model; - Ok(()) - } - - fn available_models(&self) -> Vec<&'static str> { - Self::known_models() - } - - fn available_models_display(&self) -> Vec { - self.all_display_models() - } - - fn available_models_for_switching(&self) -> Vec { - self.all_display_models() - } - - fn model_routes(&self) -> Vec { - let legacy_models = self - .legacy_models - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); - let profile_required_models = self - .profile_required_models - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); - self.all_display_models() - .into_iter() - .map(|model| { - let info = Self::model_info(&model); - let is_legacy = legacy_models.contains(&model); - let profile_foundation = Self::foundation_model_id_from_profile_id(&model); - let missing_required_profile = profile_foundation.is_none() - && profile_required_models.contains(&model) - && self.profile_route_for_model(&model).is_none(); - let mut features = Vec::new(); - if info.supports_tools { - features.push("tools"); - } else { - features.push("no tools"); - } - if info.supports_vision { - features.push("vision"); - } - if info.supports_reasoning { - features.push("reasoning"); - } - ModelRoute { - model: model.clone(), - provider: "AWS Bedrock".to_string(), - api_method: "bedrock".to_string(), - available: !is_legacy && !missing_required_profile, - detail: if is_legacy { - "legacy Bedrock model; choose an active model or inference profile" - .to_string() - } else if missing_required_profile { - "requires an inference profile; run /refresh-model-list or allow bedrock:ListInferenceProfiles" - .to_string() - } else { - let mut parts = Vec::new(); - if let Some(foundation) = profile_foundation { - parts.push(format!("inference profile for {}", foundation)); - } - parts.push(format!("context ~{} tokens", info.context_tokens)); - parts.push(format!("max output ~{}", info.max_output_tokens)); - parts.push(features.join(", ")); - format!( - "ConverseStream · {}", - parts - .into_iter() - .filter(|part| !part.trim().is_empty()) - .collect::>() - .join(" · ") - ) - }, - cheapness: Self::route_pricing(&model), - } - }) - .collect() - } - - async fn prefetch_models(&self) -> Result<()> { - self.refresh_catalog().await.map(|_| ()) - } - - async fn refresh_model_catalog(&self) -> Result { - let before_models = self.available_models_display(); - let before_routes = self.model_routes(); - self.refresh_catalog().await?; - let after_models = self.available_models_display(); - let after_routes = self.model_routes(); - Ok(summarize_model_catalog_refresh( - before_models, - after_models, - before_routes, - after_routes, - )) - } - - fn context_window(&self) -> usize { - Self::model_info(&self.model()).context_tokens - } - - fn supports_compaction(&self) -> bool { - true - } - - fn uses_jcode_compaction(&self) -> bool { - true - } - - fn fork(&self) -> Arc { - Arc::new(Self { - model: Arc::new(RwLock::new(self.model())), - fetched_models: self.fetched_models.clone(), - fetched_inference_profiles: self.fetched_inference_profiles.clone(), - profile_required_models: self.profile_required_models.clone(), - inference_profile_routes: self.inference_profile_routes.clone(), - legacy_models: self.legacy_models.clone(), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::ffi::{OsStr, OsString}; - - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - crate::env::set_var(key, value); - Self { key, previous } - } - - fn remove(key: &'static str) -> Self { - let previous = std::env::var_os(key); - crate::env::remove_var(key); - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(value) = self.previous.as_ref() { - crate::env::set_var(self.key, value); - } else { - crate::env::remove_var(self.key); - } - } - } - - #[test] - fn detects_env_credentials_requires_region_and_credential_hint() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let _removed = [ - "JCODE_BEDROCK_ENABLE", - API_KEY_ENV, - REGION_ENV, - "AWS_REGION", - "AWS_DEFAULT_REGION", - "AWS_PROFILE", - "JCODE_BEDROCK_PROFILE", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SHARED_CREDENTIALS_FILE", - "AWS_CONFIG_FILE", - ] - .map(EnvVarGuard::remove); - crate::env::set_var(REGION_ENV, "us-east-1"); - assert!(!BedrockProvider::has_credentials()); - crate::env::set_var("AWS_PROFILE", "test"); - assert!(BedrockProvider::has_credentials()); - } - - #[test] - fn explicit_enable_marks_configured_for_instance_metadata_credentials() { - let _guard = crate::storage::lock_test_env(); - crate::env::set_var("JCODE_BEDROCK_ENABLE", "1"); - assert!(BedrockProvider::has_credentials()); - crate::env::remove_var("JCODE_BEDROCK_ENABLE"); - } - - #[test] - fn detects_bedrock_login_env_file_credentials() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - for key in [ - "JCODE_BEDROCK_ENABLE", - API_KEY_ENV, - REGION_ENV, - "AWS_REGION", - "AWS_DEFAULT_REGION", - "AWS_PROFILE", - "JCODE_BEDROCK_PROFILE", - "AWS_ACCESS_KEY_ID", - ] { - crate::env::remove_var(key); - } - - assert!(!BedrockProvider::has_credentials()); - crate::provider_catalog::save_env_value_to_env_file( - API_KEY_ENV, - ENV_FILE, - Some("test-key"), - ) - .unwrap(); - crate::env::remove_var(API_KEY_ENV); - assert!(!BedrockProvider::has_credentials()); - - crate::provider_catalog::save_env_value_to_env_file( - REGION_ENV, - ENV_FILE, - Some("us-east-2"), - ) - .unwrap(); - crate::env::remove_var(REGION_ENV); - - assert_eq!( - BedrockProvider::configured_bearer_token().as_deref(), - Some("test-key") - ); - assert_eq!( - BedrockProvider::configured_region().as_deref(), - Some("us-east-2") - ); - assert!(BedrockProvider::has_credentials()); - } - - #[test] - fn switches_arbitrary_model_ids() { - let p = BedrockProvider::new(); - p.set_model("us.anthropic.claude-3-5-sonnet-20241022-v2:0") - .unwrap(); - assert_eq!(p.model(), "us.anthropic.claude-3-5-sonnet-20241022-v2:0"); - } - - #[test] - fn maps_profile_required_foundation_model_to_inference_profile() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - p.profile_required_models - .write() - .unwrap() - .insert("amazon.nova-2-lite-v1:0".to_string()); - p.inference_profile_routes.write().unwrap().insert( - "amazon.nova-2-lite-v1:0".to_string(), - "us.amazon.nova-2-lite-v1:0".to_string(), - ); - - p.set_model("amazon.nova-2-lite-v1:0").unwrap(); - - assert_eq!(p.model(), "us.amazon.nova-2-lite-v1:0"); - } - - #[test] - fn maps_foundation_model_from_stale_cached_profile_list() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_inference_profiles.write().unwrap() = vec![ - "global.amazon.nova-2-lite-v1:0".to_string(), - "us.amazon.nova-2-lite-v1:0".to_string(), - ]; - - p.set_model("amazon.nova-2-lite-v1:0").unwrap(); - - assert_eq!(p.model(), "us.amazon.nova-2-lite-v1:0"); - } - - #[test] - fn hides_profile_required_foundation_model_when_profile_route_exists() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; - *p.fetched_inference_profiles.write().unwrap() = - vec!["us.amazon.nova-2-lite-v1:0".to_string()]; - p.profile_required_models - .write() - .unwrap() - .insert("amazon.nova-2-lite-v1:0".to_string()); - p.inference_profile_routes.write().unwrap().insert( - "amazon.nova-2-lite-v1:0".to_string(), - "us.amazon.nova-2-lite-v1:0".to_string(), - ); - - let display = p.all_display_models(); - - assert!( - !display - .iter() - .any(|model| model == "amazon.nova-2-lite-v1:0") - ); - assert!( - display - .iter() - .any(|model| model == "us.amazon.nova-2-lite-v1:0") - ); - } - - #[test] - fn hides_foundation_model_when_profile_route_exists() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; - *p.fetched_inference_profiles.write().unwrap() = - vec!["us.amazon.nova-2-lite-v1:0".to_string()]; - p.inference_profile_routes.write().unwrap().insert( - "amazon.nova-2-lite-v1:0".to_string(), - "us.amazon.nova-2-lite-v1:0".to_string(), - ); - - let display = p.all_display_models(); - - assert!( - !display - .iter() - .any(|model| model == "amazon.nova-2-lite-v1:0") - ); - assert!( - display - .iter() - .any(|model| model == "us.amazon.nova-2-lite-v1:0") - ); - } - - #[test] - fn profile_required_foundation_model_without_profile_route_is_disabled() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; - p.profile_required_models - .write() - .unwrap() - .insert("amazon.nova-2-lite-v1:0".to_string()); - - let route = p - .model_routes() - .into_iter() - .find(|route| route.model == "amazon.nova-2-lite-v1:0") - .expect("profile-required foundation model should be listed with a reason"); - - assert!(!route.available); - assert!(route.detail.contains("requires an inference profile")); - } - - #[test] - fn global_inference_profiles_use_foundation_capabilities_and_detail() { - let p = BedrockProvider::new(); - *p.fetched_inference_profiles.write().unwrap() = - vec!["global.amazon.nova-2-lite-v1:0".to_string()]; - - let route = p - .model_routes() - .into_iter() - .find(|route| route.model == "global.amazon.nova-2-lite-v1:0") - .expect("global inference profile should be listed"); - - assert!(route.available); - assert!( - route - .detail - .contains("inference profile for amazon.nova-2-lite-v1:0") - ); - assert!(route.detail.contains("tools")); - assert!(!route.detail.contains("no tools")); - } - - #[test] - fn ignores_persisted_bedrock_catalog_from_different_region() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - { - let _region = EnvVarGuard::set(REGION_ENV, "us-east-1"); - BedrockProvider::persist_catalog( - &["openai.gpt-oss-120b-1:0".to_string()], - &[], - &HashSet::new(), - &HashMap::new(), - &HashSet::new(), - ); - } - let _region = EnvVarGuard::set(REGION_ENV, "us-east-2"); - - let p = BedrockProvider::new(); - - assert!(p.fetched_models.read().unwrap().is_empty()); - } - - #[test] - fn prefers_region_inference_profile_over_global_profile() { - let _guard = crate::storage::lock_test_env(); - let _region = EnvVarGuard::set(REGION_ENV, "us-east-2"); - let mut routes = HashMap::new(); - - BedrockProvider::insert_preferred_profile_route( - &mut routes, - "amazon.nova-2-lite-v1:0", - "global.amazon.nova-2-lite-v1:0", - ); - BedrockProvider::insert_preferred_profile_route( - &mut routes, - "amazon.nova-2-lite-v1:0", - "us.amazon.nova-2-lite-v1:0", - ); - - assert_eq!( - routes.get("amazon.nova-2-lite-v1:0").map(String::as_str), - Some("us.amazon.nova-2-lite-v1:0") - ); - } - - #[test] - fn known_context_and_vision_capabilities() { - let p = BedrockProvider::new(); - p.set_model("anthropic.claude-3-5-sonnet-20241022-v2:0") - .unwrap(); - assert!(p.supports_image_input()); - assert_eq!(p.context_window(), 200_000); - p.set_model("amazon.nova-micro-v1:0").unwrap(); - assert!(!p.supports_image_input()); - assert_eq!(p.context_window(), 128_000); - } - - #[test] - fn known_no_tool_models_do_not_advertise_tools() { - assert!(!BedrockProvider::model_info("us.deepseek.r1-v1:0").supports_tools); - assert!(!BedrockProvider::model_info("deepseek.v3.2").supports_tools); - assert!( - !BedrockProvider::model_info("mistral.mistral-large-3-675b-instruct").supports_tools - ); - assert!(!BedrockProvider::model_info("openai.gpt-oss-120b-1:0").supports_tools); - assert!(BedrockProvider::model_info("us.amazon.nova-2-lite-v1:0").supports_tools); - assert!(BedrockProvider::model_info("us.anthropic.claude-sonnet-4-6").supports_tools); - } - - #[test] - fn error_classification_mentions_model_access() { - let message = BedrockProvider::classify_error_message( - "ValidationException: The provided model identifier is invalid", - ); - assert!(message.contains("model")); - assert!(message.contains("region")); - } - - #[test] - fn error_classification_mentions_legacy_models() { - let message = BedrockProvider::classify_error_message( - "Access denied. This Model is marked by provider as Legacy and you have not been actively using the model in the last 30 days", - ); - assert!(message.contains("legacy")); - assert!(message.contains("active")); - assert!(!message.starts_with("AWS IAM denied")); - } - - #[test] - fn tool_use_streaming_error_is_not_classified_as_legacy_sdk_type_name() { - let message = BedrockProvider::classify_error_message( - "ValidationException: This model doesn't support tool use in streaming mode. extensions_1x: {hyper_util::client::legacy::connect::http::HttpInfo}", - ); - assert!(message.contains("does not support tool use")); - assert!(!message.starts_with("This Bedrock model is marked as legacy")); - } - - #[test] - fn expired_sso_error_is_concise_and_actionable() { - let message = BedrockProvider::classify_error_message( - "ServiceError(ServiceError { source: AccessDeniedException(AccessDeniedException { message: Some(\"Bearer Token has expired\") }) })", - ); - assert_eq!( - message, - "AWS SSO/session credentials look expired. Run `aws sso login --profile ` and retry." - ); - } - - #[test] - fn missing_credentials_error_omits_sdk_blob() { - let message = BedrockProvider::classify_error_message( - "CredentialsNotLoaded: could not load credentials from any provider; extensions_1x: noisy sdk internals", - ); - assert!(message.contains("AWS credentials were not found")); - assert!(!message.contains("extensions_1x")); - } - - #[test] - fn legacy_model_route_is_unavailable_with_reason() { - let _guard = crate::storage::lock_test_env(); - let temp = tempfile::tempdir().unwrap(); - let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); - let p = BedrockProvider::new(); - *p.fetched_models.write().unwrap() = - vec!["anthropic.claude-3-haiku-20240307-v1:0".to_string()]; - p.legacy_models - .write() - .unwrap() - .insert("anthropic.claude-3-haiku-20240307-v1:0".to_string()); - - let route = p - .model_routes() - .into_iter() - .find(|route| route.model == "anthropic.claude-3-haiku-20240307-v1:0") - .expect("legacy route should be listed"); - - assert!(!route.available); - assert!(route.detail.contains("legacy")); - } - - #[tokio::test] - #[ignore = "requires AWS credentials and enabled Bedrock model access"] - async fn bedrock_live_smoke_test() { - if std::env::var("JCODE_BEDROCK_LIVE_TEST").ok().as_deref() != Some("1") { - return; - } - let provider = BedrockProvider::new(); - let output = provider - .complete_simple("say bedrock ok and nothing else", "") - .await - .expect("live Bedrock completion"); - assert!(output.to_ascii_lowercase().contains("bedrock ok")); - } -} +pub use jcode_provider_bedrock::{API_KEY_ENV, BedrockProvider, ENV_FILE, REGION_ENV}; diff --git a/crates/jcode-provider-bedrock/Cargo.toml b/crates/jcode-provider-bedrock/Cargo.toml new file mode 100644 index 000000000..4a7dd5c0d --- /dev/null +++ b/crates/jcode-provider-bedrock/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "jcode-provider-bedrock" +version = "0.1.0" +edition = "2024" + +[lib] +name = "jcode_provider_bedrock" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +aws-config = "1.8.16" +aws-credential-types = "1.2.14" +aws-sdk-bedrock = "1.141.0" +aws-sdk-bedrockruntime = "1.130.0" +aws-sdk-sts = "1.103.0" +aws-smithy-types = "1.4.7" +aws-types = "1.3.15" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +jcode-core = { path = "../jcode-core" } +jcode-logging = { path = "../jcode-logging" } +jcode-message-types = { path = "../jcode-message-types" } +jcode-provider-core = { path = "../jcode-provider-core" } +jcode-provider-env = { path = "../jcode-provider-env" } +jcode-storage = { path = "../jcode-storage" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "process", "rt", "sync"] } +tokio-stream = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/jcode-provider-bedrock/src/lib.rs b/crates/jcode-provider-bedrock/src/lib.rs new file mode 100644 index 000000000..9a6f93472 --- /dev/null +++ b/crates/jcode-provider-bedrock/src/lib.rs @@ -0,0 +1,1758 @@ +use anyhow::{Context, Result}; +use async_trait::async_trait; +use aws_config::BehaviorVersion; +use aws_credential_types::Credentials; +use aws_sdk_bedrock::Client as BedrockControlClient; +use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient; +use aws_sdk_bedrockruntime::types::{ + ContentBlock, ContentBlockDelta, ContentBlockStart, ConversationRole, ConverseStreamOutput, + ImageBlock, ImageFormat, ImageSource, InferenceConfiguration, Message, + ReasoningContentBlockDelta, SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, + ToolSpecification, +}; +use aws_smithy_types::Blob; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use jcode_message_types::{ + ContentBlock as JContentBlock, Message as JMessage, Role as JRole, StreamEvent, ToolDefinition, +}; +use jcode_provider_core::{ + DEFAULT_CONTEXT_LIMIT, EventStream, ModelCatalogRefreshSummary, ModelRoute, Provider, + RouteCheapnessEstimate, RouteCostConfidence, RouteCostSource, summarize_model_catalog_refresh, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::pin::Pin; +use std::sync::{Arc, RwLock}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +const DEFAULT_MODEL: &str = "anthropic.claude-3-5-sonnet-20241022-v2:0"; +const DEFAULT_MAX_OUTPUT_TOKENS: usize = 4096; +pub const ENV_FILE: &str = "bedrock.env"; +pub const API_KEY_ENV: &str = "AWS_BEARER_TOKEN_BEDROCK"; +pub const REGION_ENV: &str = "JCODE_BEDROCK_REGION"; + +#[derive(Debug, Clone)] +struct BedrockModelInfo { + context_tokens: usize, + max_output_tokens: usize, + supports_tools: bool, + supports_vision: bool, + supports_reasoning: bool, + pricing: Option<(u64, u64)>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedCatalog { + models: Vec, + inference_profiles: Vec, + #[serde(default)] + profile_required_models: Vec, + #[serde(default)] + inference_profile_routes: HashMap, + #[serde(default)] + legacy_models: Vec, + region: Option, + fetched_at_rfc3339: String, +} + +pub struct BedrockProvider { + model: Arc>, + fetched_models: Arc>>, + fetched_inference_profiles: Arc>>, + profile_required_models: Arc>>, + inference_profile_routes: Arc>>, + legacy_models: Arc>>, +} + +impl BedrockProvider { + pub fn new() -> Self { + let model = + std::env::var("JCODE_BEDROCK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); + let provider = Self { + model: Arc::new(RwLock::new(model)), + fetched_models: Arc::new(RwLock::new(Vec::new())), + fetched_inference_profiles: Arc::new(RwLock::new(Vec::new())), + profile_required_models: Arc::new(RwLock::new(HashSet::new())), + inference_profile_routes: Arc::new(RwLock::new(HashMap::new())), + legacy_models: Arc::new(RwLock::new(HashSet::new())), + }; + provider.seed_cached_catalog(); + provider + } + + pub fn has_credentials() -> bool { + let explicitly_enabled = std::env::var("JCODE_BEDROCK_ENABLE") + .ok() + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + if explicitly_enabled { + return true; + } + + let has_region = Self::configured_region().is_some(); + let has_credential_hint = Self::configured_bearer_token().is_some() + || std::env::var_os("AWS_ACCESS_KEY_ID").is_some() + || std::env::var_os("AWS_PROFILE").is_some() + || std::env::var_os("JCODE_BEDROCK_PROFILE").is_some() + || std::env::var_os("AWS_WEB_IDENTITY_TOKEN_FILE").is_some() + || std::env::var_os("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some() + || std::env::var_os("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some() + || std::env::var_os("AWS_SHARED_CREDENTIALS_FILE").is_some() + || std::env::var_os("AWS_CONFIG_FILE").is_some(); + + has_region && has_credential_hint + } + + async fn sdk_config() -> aws_types::SdkConfig { + let mut loader = aws_config::defaults(BehaviorVersion::latest()); + if let Some(token) = Self::configured_bearer_token() { + jcode_core::env::set_var(API_KEY_ENV, token); + } + if let Some(region) = Self::configured_region() { + loader = loader.region(aws_types::region::Region::new(region)); + } + if let Ok(profile) = + std::env::var("JCODE_BEDROCK_PROFILE").or_else(|_| std::env::var("AWS_PROFILE")) + { + if let Some(credentials) = Self::credentials_from_aws_login_profile(&profile).await { + loader = loader.credentials_provider(credentials); + } + loader = loader.profile_name(profile); + } + loader.load().await + } + + async fn credentials_from_aws_login_profile(profile: &str) -> Option { + if std::env::var_os("AWS_ACCESS_KEY_ID").is_some() + || std::env::var_os("AWS_SECRET_ACCESS_KEY").is_some() + || std::env::var_os("AWS_BEARER_TOKEN_BEDROCK").is_some() + { + return None; + } + + let output = tokio::process::Command::new("aws") + .args([ + "configure", + "export-credentials", + "--profile", + profile, + "--format", + "env-no-export", + ]) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + let mut access_key_id = None; + let mut secret_access_key = None; + let mut session_token = None; + for line in stdout.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + match key.trim() { + "AWS_ACCESS_KEY_ID" => access_key_id = Some(value.trim().to_string()), + "AWS_SECRET_ACCESS_KEY" => secret_access_key = Some(value.trim().to_string()), + "AWS_SESSION_TOKEN" => session_token = Some(value.trim().to_string()), + _ => {} + } + } + + Some(Credentials::new( + access_key_id?, + secret_access_key?, + session_token, + None, + "aws-cli-export-credentials", + )) + } + + async fn runtime_client() -> BedrockRuntimeClient { + let config = Self::sdk_config().await; + BedrockRuntimeClient::new(&config) + } + + async fn control_client() -> BedrockControlClient { + let config = Self::sdk_config().await; + BedrockControlClient::new(&config) + } + + async fn validate_credentials_if_requested() -> Result<()> { + let validate = std::env::var("JCODE_BEDROCK_VALIDATE_STS") + .ok() + .map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "no")) + .unwrap_or(false); + if !validate { + return Ok(()); + } + let config = Self::sdk_config().await; + let client = aws_sdk_sts::Client::new(&config); + client + .get_caller_identity() + .send() + .await + .map(|_| ()) + .map_err(|err| { + anyhow::anyhow!(Self::classify_error_message(&Self::sdk_error_message(&err))) + }) + } + + fn configured_region() -> Option { + Self::env_or_config(REGION_ENV) + .or_else(|| Self::env_or_config("AWS_REGION")) + .or_else(|| Self::env_or_config("AWS_DEFAULT_REGION")) + } + + pub fn configured_bearer_token() -> Option { + jcode_provider_env::load_api_key_from_env_or_config(API_KEY_ENV, ENV_FILE) + } + + fn env_or_config(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .or_else(|| jcode_provider_env::load_env_value_from_env_or_config(name, ENV_FILE)) + } + + fn persisted_catalog_path() -> Result { + Ok(jcode_storage::app_config_dir()?.join("bedrock_models_cache.json")) + } + + fn load_persisted_catalog() -> Option { + let path = Self::persisted_catalog_path().ok()?; + jcode_storage::read_json(&path).ok() + } + + fn persist_catalog( + models: &[String], + inference_profiles: &[String], + profile_required_models: &HashSet, + inference_profile_routes: &HashMap, + legacy_models: &HashSet, + ) { + let Ok(path) = Self::persisted_catalog_path() else { + return; + }; + let payload = PersistedCatalog { + models: models.to_vec(), + inference_profiles: inference_profiles.to_vec(), + profile_required_models: profile_required_models.iter().cloned().collect(), + inference_profile_routes: inference_profile_routes.clone(), + legacy_models: legacy_models.iter().cloned().collect(), + region: Self::configured_region(), + fetched_at_rfc3339: chrono::Utc::now().to_rfc3339(), + }; + if let Err(err) = jcode_storage::write_json(&path, &payload) { + jcode_logging::warn(&format!( + "Failed to persist Bedrock model catalog {}: {}", + path.display(), + err + )); + } + } + + fn seed_cached_catalog(&self) { + if let Some(catalog) = Self::load_persisted_catalog() { + let configured_region = Self::configured_region(); + if catalog.region.as_deref() != configured_region.as_deref() { + jcode_logging::info(&format!( + "Ignoring Bedrock model cache for region {:?}; configured region is {:?}", + catalog.region, configured_region + )); + return; + } + let PersistedCatalog { + models: cached_models, + inference_profiles, + profile_required_models, + inference_profile_routes, + legacy_models, + .. + } = catalog; + let mut inference_profile_routes = inference_profile_routes; + Self::merge_profile_routes_from_profile_ids( + &mut inference_profile_routes, + inference_profiles.iter(), + ); + if let Ok(mut guard) = self.fetched_models.write() { + *guard = cached_models; + } + if let Ok(mut profiles) = self.fetched_inference_profiles.write() { + *profiles = inference_profiles; + } + if let Ok(mut required) = self.profile_required_models.write() { + *required = profile_required_models.into_iter().collect(); + } + if let Ok(mut routes) = self.inference_profile_routes.write() { + *routes = inference_profile_routes; + } + if let Ok(mut legacy) = self.legacy_models.write() { + *legacy = legacy_models.into_iter().collect(); + } + } + } + + fn classify_error_message(raw: &str) -> String { + let lower = raw.to_ascii_lowercase(); + let is_legacy_model_error = lower.contains("marked by provider as legacy") + || lower.contains("model is marked") && lower.contains("legacy") + || lower.contains("have not been actively using the model in the last 30 days"); + if is_legacy_model_error { + return format!( + "{} Original error: {}", + "This Bedrock model is marked as legacy for this account. Choose an active Bedrock model or an active inference profile instead.", + raw.trim() + ); + } else if lower.contains("doesn't support tool use") + || lower.contains("does not support tool use") + || lower.contains("tool use in streaming mode") + { + return format!( + "{} Original error: {}", + "This Bedrock model does not support tool use with streaming. Choose a Bedrock model with tool support, such as a Claude or Nova profile, or use a no-tools Bedrock model route.", + raw.trim() + ); + } else if lower.contains("no credentials") + || lower.contains("could not load credentials") + || lower.contains("credentials") && lower.contains("not loaded") + { + return "AWS credentials were not found. Set AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or run `aws sso login`.".to_string(); + } else if lower.contains("expired") || lower.contains("sso") && lower.contains("token") { + return "AWS SSO/session credentials look expired. Run `aws sso login --profile ` and retry.".to_string(); + } + + let hint = if lower.contains("accessdenied") + || lower.contains("access denied") + || lower.contains("not authorized") + { + "AWS IAM denied the Bedrock request. Ensure the principal can call bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream, bedrock:ListFoundationModels, and bedrock:ListInferenceProfiles as needed." + } else if lower.contains("validationexception") && lower.contains("model") + || lower.contains("model") && lower.contains("not found") + || lower.contains("resource not found") + { + "Bedrock did not recognize this model in the selected region/account. Check model ID, inference profile ID, region, and model access." + } else if lower.contains("throttl") + || lower.contains("too many requests") + || lower.contains("rate exceeded") + { + "Bedrock throttled the request. Retry later or request a quota increase." + } else if lower.contains("region") && lower.contains("missing") { + "AWS region is missing. Set AWS_REGION or JCODE_BEDROCK_REGION." + } else { + "Bedrock request failed. Check AWS credentials, region, model access, and IAM permissions." + }; + format!("{} Original error: {}", hint, raw.trim()) + } + + fn sdk_error_message(err: &(impl std::fmt::Display + std::fmt::Debug)) -> String { + let display = err.to_string(); + let trimmed = display.trim(); + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("service error") + || trimmed.eq_ignore_ascii_case("dispatch failure") + { + format!("{err:?}") + } else { + display + } + } + + fn json_to_document(value: &serde_json::Value) -> aws_smithy_types::Document { + match value { + serde_json::Value::Null => aws_smithy_types::Document::Null, + serde_json::Value::Bool(v) => aws_smithy_types::Document::Bool(*v), + serde_json::Value::Number(n) => { + if let Some(v) = n.as_u64() { + aws_smithy_types::Document::from(v) + } else if let Some(v) = n.as_i64() { + aws_smithy_types::Document::from(v) + } else if let Some(v) = n.as_f64() { + aws_smithy_types::Document::from(v) + } else { + aws_smithy_types::Document::Null + } + } + serde_json::Value::String(v) => aws_smithy_types::Document::String(v.clone()), + serde_json::Value::Array(values) => aws_smithy_types::Document::Array( + values.iter().map(Self::json_to_document).collect(), + ), + serde_json::Value::Object(map) => aws_smithy_types::Document::Object( + map.iter() + .map(|(key, value)| (key.clone(), Self::json_to_document(value))) + .collect::>(), + ), + } + } + + fn image_format_for_media_type(media_type: &str) -> Option { + match media_type.trim().to_ascii_lowercase().as_str() { + "image/png" => Some(ImageFormat::Png), + "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), + "image/gif" => Some(ImageFormat::Gif), + "image/webp" => Some(ImageFormat::Webp), + _ => None, + } + } + + fn image_block(media_type: &str, data: &str) -> Result { + let format = Self::image_format_for_media_type(media_type).ok_or_else(|| { + anyhow::anyhow!( + "Bedrock image input does not support media type `{}`", + media_type + ) + })?; + let bytes = BASE64.decode(data).with_context(|| { + format!("Failed to decode {} image payload for Bedrock", media_type) + })?; + ImageBlock::builder() + .format(format) + .source(ImageSource::Bytes(Blob::new(bytes))) + .build() + .context("Failed to build Bedrock image block") + } + + fn to_bedrock_messages(messages: &[JMessage], allow_images: bool) -> Result> { + messages + .iter() + .filter_map(|msg| { + let role = match msg.role { + JRole::User => ConversationRole::User, + JRole::Assistant => ConversationRole::Assistant, + }; + let mut content = Vec::new(); + for block in &msg.content { + match block { + JContentBlock::Text { text, .. } => { + content.push(ContentBlock::Text(text.clone())) + } + JContentBlock::Image { media_type, data } => { + if !allow_images { + return Some(Err(anyhow::anyhow!( + "Current Bedrock model does not advertise image input support" + ))); + } + match Self::image_block(media_type, data) { + Ok(image) => content.push(ContentBlock::Image(image)), + Err(err) => return Some(Err(err)), + } + } + JContentBlock::ToolResult { + tool_use_id, + content: text, + is_error, + } => { + let status = if is_error.unwrap_or(false) { + aws_sdk_bedrockruntime::types::ToolResultStatus::Error + } else { + aws_sdk_bedrockruntime::types::ToolResultStatus::Success + }; + let result = + match aws_sdk_bedrockruntime::types::ToolResultBlock::builder() + .tool_use_id(tool_use_id) + .status(status) + .content( + aws_sdk_bedrockruntime::types::ToolResultContentBlock::Text( + text.clone(), + ), + ) + .build() + { + Ok(result) => result, + Err(err) => return Some(Err(anyhow::anyhow!(err))), + }; + content.push(ContentBlock::ToolResult(result)); + } + JContentBlock::ToolUse { + id, name, input, .. + } => { + let tool_use = + match aws_sdk_bedrockruntime::types::ToolUseBlock::builder() + .tool_use_id(id) + .name(name) + .input(Self::json_to_document(input)) + .build() + { + Ok(tool_use) => tool_use, + Err(err) => return Some(Err(anyhow::anyhow!(err))), + }; + content.push(ContentBlock::ToolUse(tool_use)); + } + _ => {} + } + } + if content.is_empty() { + return None; + } + Some( + Message::builder() + .role(role) + .set_content(Some(content)) + .build() + .map_err(|err| anyhow::anyhow!(err)), + ) + }) + .collect() + } + + fn tool_config(tools: &[ToolDefinition]) -> Option { + if tools.is_empty() { + return None; + } + let bedrock_tools = tools + .iter() + .filter_map(|tool| { + let schema = ToolInputSchema::Json(Self::json_to_document(&tool.input_schema)); + ToolSpecification::builder() + .name(&tool.name) + .description(tool.description.clone()) + .input_schema(schema) + .build() + .ok() + .map(Tool::ToolSpec) + }) + .collect::>(); + if bedrock_tools.is_empty() { + None + } else { + ToolConfiguration::builder() + .set_tools(Some(bedrock_tools)) + .build() + .ok() + } + } + + fn inference_config() -> Option { + let max_tokens = std::env::var("JCODE_BEDROCK_MAX_TOKENS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0); + let temperature = std::env::var("JCODE_BEDROCK_TEMPERATURE") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| (0.0..=1.0).contains(v)); + let top_p = std::env::var("JCODE_BEDROCK_TOP_P") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| (0.0..=1.0).contains(v)); + let stop_sequences = std::env::var("JCODE_BEDROCK_STOP_SEQUENCES") + .ok() + .map(|v| { + v.split(',') + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) + .collect::>() + }) + .filter(|v| !v.is_empty()); + if max_tokens.is_none() + && temperature.is_none() + && top_p.is_none() + && stop_sequences.is_none() + { + return None; + } + Some( + InferenceConfiguration::builder() + .set_max_tokens(max_tokens) + .set_temperature(temperature) + .set_top_p(top_p) + .set_stop_sequences(stop_sequences) + .build(), + ) + } + + fn normalize_model_id(model: &str) -> String { + let mut value = model.trim().to_string(); + if let Some((_, tail)) = value.rsplit_once('/') { + value = tail.to_string(); + } + for prefix in ["us.", "eu.", "apac.", "global."] { + if let Some(stripped) = value.strip_prefix(prefix) { + value = stripped.to_string(); + break; + } + } + value + } + + fn foundation_model_id_from_arn(arn: &str) -> Option { + arn.rsplit_once("foundation-model/") + .map(|(_, model)| model.trim()) + .filter(|model| !model.is_empty()) + .map(str::to_string) + } + + fn inference_profile_id_from_arn(arn: &str) -> Option { + arn.rsplit_once("inference-profile/") + .map(|(_, profile)| profile.trim()) + .filter(|profile| !profile.is_empty()) + .map(str::to_string) + } + + fn foundation_model_id_from_profile_id(profile_id: &str) -> Option { + let id = profile_id.trim(); + let id = Self::inference_profile_id_from_arn(id).unwrap_or_else(|| id.to_string()); + for prefix in ["us.", "eu.", "apac.", "global."] { + if let Some(model) = id.strip_prefix(prefix) + && !model.is_empty() + { + return Some(model.to_string()); + } + } + None + } + + fn region_profile_prefix() -> Option<&'static str> { + let region = Self::configured_region()?; + if region.starts_with("us-") { + Some("us.") + } else if region.starts_with("eu-") { + Some("eu.") + } else if region.starts_with("ap-") { + Some("apac.") + } else { + None + } + } + + fn inference_profile_priority(profile_id: &str) -> u8 { + let id = profile_id.trim().to_ascii_lowercase(); + if let Some(prefix) = Self::region_profile_prefix() + && id.starts_with(prefix) + { + return 0; + } + if id.starts_with("us.") || id.starts_with("eu.") || id.starts_with("apac.") { + 1 + } else if id.starts_with("global.") { + 2 + } else { + 3 + } + } + + fn insert_preferred_profile_route( + routes: &mut HashMap, + foundation_model: &str, + profile_id: &str, + ) { + let foundation_model = foundation_model.trim(); + let profile_id = profile_id.trim(); + if foundation_model.is_empty() || profile_id.is_empty() { + return; + } + let should_replace = routes + .get(foundation_model) + .map(|current| { + Self::inference_profile_priority(profile_id) + < Self::inference_profile_priority(current) + }) + .unwrap_or(true); + if should_replace { + routes.insert(foundation_model.to_string(), profile_id.to_string()); + } + } + + fn merge_profile_routes_from_profile_ids( + routes: &mut HashMap, + profiles: impl IntoIterator>, + ) { + for profile in profiles { + let profile = profile.as_ref().trim(); + let Some(foundation_model) = Self::foundation_model_id_from_profile_id(profile) else { + continue; + }; + let profile_id = + Self::inference_profile_id_from_arn(profile).unwrap_or_else(|| profile.to_string()); + Self::insert_preferred_profile_route(routes, &foundation_model, &profile_id); + } + } + + fn profile_route_for_model(&self, model: &str) -> Option { + let model = model.trim(); + if model.is_empty() { + return None; + } + + if let Ok(routes) = self.inference_profile_routes.read() + && let Some(route) = routes.get(model).cloned() + { + return Some(route); + } + + if let Ok(profiles) = self.fetched_inference_profiles.read() { + let mut derived = HashMap::new(); + Self::merge_profile_routes_from_profile_ids(&mut derived, profiles.iter()); + if let Some(route) = derived.get(model).cloned() { + return Some(route); + } + } + + None + } + + pub fn is_bedrock_model_id(model: &str) -> bool { + let trimmed = model.trim(); + if trimmed.is_empty() { + return false; + } + if trimmed.starts_with("arn:aws:bedrock:") { + return true; + } + + let id = Self::normalize_model_id(trimmed).to_ascii_lowercase(); + id.starts_with("anthropic.") + || id.starts_with("amazon.") + || id.starts_with("cohere.") + || id.starts_with("ai21.") + || id.starts_with("meta.") + || id.starts_with("mistral.") + || id.starts_with("stability.") + || id.starts_with("writer.") + || id.starts_with("deepseek.") + || id.starts_with("openai.") + || id.starts_with("qwen.") + || id.starts_with("moonshot.") + || id.starts_with("moonshotai.") + || id.starts_with("minimax.") + || id.starts_with("zai.") + || id.starts_with("google.") + || id.starts_with("nvidia.") + } + + fn model_info(model: &str) -> BedrockModelInfo { + let id = Self::normalize_model_id(model).to_ascii_lowercase(); + if id.contains("claude-opus-4") || id.contains("claude-sonnet-4") { + BedrockModelInfo { + context_tokens: 200_000, + max_output_tokens: 64_000, + supports_tools: true, + supports_vision: true, + supports_reasoning: true, + pricing: Some((3_000_000, 15_000_000)), + } + } else if id.contains("claude-3-7-sonnet") || id.contains("claude-3-5-sonnet") { + BedrockModelInfo { + context_tokens: 200_000, + max_output_tokens: 8_192, + supports_tools: true, + supports_vision: true, + supports_reasoning: id.contains("3-7"), + pricing: Some((3_000_000, 15_000_000)), + } + } else if id.contains("claude-3-5-haiku") || id.contains("claude-3-haiku") { + BedrockModelInfo { + context_tokens: 200_000, + max_output_tokens: 8_192, + supports_tools: true, + supports_vision: true, + supports_reasoning: false, + pricing: Some((800_000, 4_000_000)), + } + } else if id.contains("amazon.nova-pro") { + BedrockModelInfo { + context_tokens: 300_000, + max_output_tokens: 5_120, + supports_tools: true, + supports_vision: true, + supports_reasoning: false, + pricing: Some((800_000, 3_200_000)), + } + } else if id.contains("amazon.nova-2-lite") || id.contains("amazon.nova-lite") { + BedrockModelInfo { + context_tokens: 300_000, + max_output_tokens: 5_120, + supports_tools: true, + supports_vision: true, + supports_reasoning: false, + pricing: Some((60_000, 240_000)), + } + } else if id.contains("amazon.nova-micro") { + BedrockModelInfo { + context_tokens: 128_000, + max_output_tokens: 5_120, + supports_tools: true, + supports_vision: false, + supports_reasoning: false, + pricing: Some((35_000, 140_000)), + } + } else if id.starts_with("deepseek.") { + BedrockModelInfo { + context_tokens: 128_000, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, + supports_tools: false, + supports_vision: false, + supports_reasoning: true, + pricing: None, + } + } else if id.contains("llama3-1-405b") || id.starts_with("meta.") { + BedrockModelInfo { + context_tokens: 128_000, + max_output_tokens: 4_096, + supports_tools: false, + supports_vision: false, + supports_reasoning: false, + pricing: Some((5_320_000, 16_000_000)), + } + } else if id.starts_with("mistral.") { + BedrockModelInfo { + context_tokens: 128_000, + max_output_tokens: 8_192, + supports_tools: false, + supports_vision: false, + supports_reasoning: false, + pricing: Some((4_000_000, 12_000_000)), + } + } else if id.starts_with("openai.") + || id.starts_with("qwen.") + || id.starts_with("moonshot.") + || id.starts_with("moonshotai.") + || id.starts_with("minimax.") + || id.starts_with("zai.") + || id.starts_with("google.") + || id.starts_with("nvidia.") + || id.starts_with("writer.") + { + BedrockModelInfo { + context_tokens: DEFAULT_CONTEXT_LIMIT, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, + supports_tools: false, + supports_vision: false, + supports_reasoning: id.contains("thinking") + || id.contains("reason") + || id.contains("gpt-oss"), + pricing: None, + } + } else { + BedrockModelInfo { + context_tokens: DEFAULT_CONTEXT_LIMIT, + max_output_tokens: DEFAULT_MAX_OUTPUT_TOKENS, + supports_tools: false, + supports_vision: false, + supports_reasoning: false, + pricing: None, + } + } + } + + fn route_pricing(model: &str) -> Option { + let info = Self::model_info(model); + info.pricing.map(|(input, output)| { + RouteCheapnessEstimate::metered( + RouteCostSource::Heuristic, + RouteCostConfidence::Medium, + input, + output, + None, + Some("AWS Bedrock public on-demand pricing heuristic; verify for your region/account".to_string()), + ) + }) + } + + fn known_models() -> Vec<&'static str> { + vec![ + "anthropic.claude-3-5-sonnet-20241022-v2:0", + "anthropic.claude-3-5-haiku-20241022-v1:0", + "anthropic.claude-3-7-sonnet-20250219-v1:0", + "anthropic.claude-sonnet-4-20250514-v1:0", + "anthropic.claude-opus-4-20250514-v1:0", + "amazon.nova-pro-v1:0", + "amazon.nova-lite-v1:0", + "amazon.nova-micro-v1:0", + "meta.llama3-1-405b-instruct-v1:0", + "mistral.mistral-large-2407-v1:0", + ] + } + + fn all_display_models(&self) -> Vec { + let mut seen = HashSet::new(); + let mut models = Vec::new(); + let inference_profile_routes = self + .inference_profile_routes + .read() + .map(|guard| guard.clone()) + .unwrap_or_default(); + let should_hide_duplicate_foundation_model = + |model: &str| inference_profile_routes.contains_key(model); + for model in Self::known_models().into_iter().map(str::to_string) { + if should_hide_duplicate_foundation_model(&model) { + continue; + } + if seen.insert(model.clone()) { + models.push(model); + } + } + if let Ok(fetched) = self.fetched_models.read() { + for model in fetched.iter() { + if should_hide_duplicate_foundation_model(model) { + continue; + } + if seen.insert(model.clone()) { + models.push(model.clone()); + } + } + } + if let Ok(profiles) = self.fetched_inference_profiles.read() { + for profile in profiles.iter() { + if seen.insert(profile.clone()) { + models.push(profile.clone()); + } + } + } + models + } + + async fn refresh_catalog(&self) -> Result<(Vec, Vec)> { + let client = Self::control_client().await; + let mut models = Vec::new(); + let mut profile_required_models = HashSet::new(); + let mut legacy_models = HashSet::new(); + let model_resp = client + .list_foundation_models() + .send() + .await + .map_err(|err| { + anyhow::anyhow!(Self::classify_error_message(&Self::sdk_error_message(&err))) + })?; + for summary in model_resp.model_summaries() { + let model_id = summary.model_id(); + if !model_id.is_empty() { + models.push(model_id.to_string()); + let inference_types = summary.inference_types_supported(); + let supports_on_demand = inference_types + .iter() + .any(|kind| kind.as_str() == "ON_DEMAND"); + let supports_inference_profile = inference_types + .iter() + .any(|kind| kind.as_str() == "INFERENCE_PROFILE"); + if supports_inference_profile && !supports_on_demand { + profile_required_models.insert(model_id.to_string()); + } + if summary + .model_lifecycle() + .map(|lifecycle| lifecycle.status().as_str() == "LEGACY") + .unwrap_or(false) + { + legacy_models.insert(model_id.to_string()); + } + } + } + models.sort(); + models.dedup(); + + let mut profiles = Vec::new(); + let mut inference_profile_routes = HashMap::new(); + match client.list_inference_profiles().send().await { + Ok(resp) => { + for summary in resp.inference_profile_summaries() { + let id = summary.inference_profile_id(); + if !id.is_empty() { + profiles.push(id.to_string()); + } + let arn = summary.inference_profile_arn(); + if !arn.is_empty() { + profiles.push(arn.to_string()); + } + if summary.status().as_str() == "ACTIVE" && !id.is_empty() { + for model in summary.models() { + if let Some(model_arn) = model.model_arn() + && let Some(foundation_model) = + Self::foundation_model_id_from_arn(model_arn) + { + Self::insert_preferred_profile_route( + &mut inference_profile_routes, + &foundation_model, + id, + ); + } + } + } + } + profiles.sort(); + profiles.dedup(); + Self::merge_profile_routes_from_profile_ids( + &mut inference_profile_routes, + profiles.iter(), + ); + } + Err(err) => { + jcode_logging::info(&format!( + "Bedrock inference profile discovery skipped: {}", + Self::classify_error_message(&Self::sdk_error_message(&err)) + )); + } + } + + if let Ok(mut guard) = self.fetched_models.write() { + *guard = models.clone(); + } + if let Ok(mut guard) = self.fetched_inference_profiles.write() { + *guard = profiles.clone(); + } + if let Ok(mut guard) = self.profile_required_models.write() { + *guard = profile_required_models.clone(); + } + if let Ok(mut guard) = self.inference_profile_routes.write() { + *guard = inference_profile_routes.clone(); + } + if let Ok(mut guard) = self.legacy_models.write() { + *guard = legacy_models.clone(); + } + Self::persist_catalog( + &models, + &profiles, + &profile_required_models, + &inference_profile_routes, + &legacy_models, + ); + Ok((models, profiles)) + } +} + +impl Default for BedrockProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for BedrockProvider { + async fn complete( + &self, + messages: &[JMessage], + tools: &[ToolDefinition], + system: &str, + _resume_session_id: Option<&str>, + ) -> Result { + Self::validate_credentials_if_requested().await?; + let model = self.model(); + let info = Self::model_info(&model); + let request_messages = Self::to_bedrock_messages(messages, info.supports_vision)?; + let tool_config = if info.supports_tools { + Self::tool_config(tools) + } else { + None + }; + let inference_config = Self::inference_config(); + let system_blocks = if system.trim().is_empty() { + None + } else { + Some(vec![SystemContentBlock::Text(system.to_string())]) + }; + let message_items = serde_json::to_value(messages) + .ok() + .and_then(|value| value.as_array().cloned()) + .unwrap_or_default(); + let system_value = (!system.trim().is_empty()).then(|| Value::String(system.to_string())); + let tools_value = if info.supports_tools && !tools.is_empty() { + serde_json::to_value(tools).ok() + } else { + None + }; + let payload = json!({ + "model": &model, + "system": system_value.as_ref(), + "messages": &message_items, + "tools": tools_value.as_ref(), + "supports_tools": info.supports_tools, + "supports_vision": info.supports_vision, + "inference_config_present": inference_config.is_some(), + }); + jcode_provider_core::log_provider_canonical_input( + "bedrock", + &model, + "bedrock_converse_logical", + &payload, + &message_items, + system_value.as_ref(), + tools_value.as_ref(), + Some(if info.supports_tools { tools.len() } else { 0 }), + &[ + ("supports_tools", info.supports_tools.to_string()), + ("supports_vision", info.supports_vision.to_string()), + ( + "inference_config_present", + inference_config.is_some().to_string(), + ), + ], + ); + let (tx, rx) = mpsc::channel::>(64); + tokio::spawn(async move { + let client = Self::runtime_client().await; + let mut req = client + .converse_stream() + .model_id(model.clone()) + .set_messages(Some(request_messages)); + if let Some(system_blocks) = system_blocks { + req = req.set_system(Some(system_blocks)); + } + if let Some(tool_config) = tool_config { + req = req.tool_config(tool_config); + } + if let Some(inference_config) = inference_config { + req = req.inference_config(inference_config); + } + let resp = match req.send().await { + Ok(resp) => resp, + Err(err) => { + let _ = tx + .send(Err(anyhow::anyhow!(Self::classify_error_message( + &Self::sdk_error_message(&err) + )))) + .await; + return; + } + }; + let mut stream = resp.stream; + let mut current_tool: Option<(String, String, String)> = None; + loop { + match stream.recv().await { + Ok(Some(event)) => match event { + ConverseStreamOutput::ContentBlockStart(start) => { + if let Some(ContentBlockStart::ToolUse(tool)) = start.start { + let id = tool.tool_use_id().to_string(); + let name = tool.name().to_string(); + current_tool = Some((id.clone(), name.clone(), String::new())); + let _ = tx.send(Ok(StreamEvent::ToolUseStart { id, name })).await; + } + } + ConverseStreamOutput::ContentBlockDelta(delta) => { + if let Some(d) = delta.delta { + match d { + ContentBlockDelta::Text(text) => { + let _ = tx.send(Ok(StreamEvent::TextDelta(text))).await; + } + ContentBlockDelta::ToolUse(tool_delta) => { + let input = tool_delta.input(); + if !input.is_empty() { + if let Some((_, _, buf)) = current_tool.as_mut() { + buf.push_str(input); + } + let _ = tx + .send(Ok(StreamEvent::ToolInputDelta( + input.to_string(), + ))) + .await; + } + } + ContentBlockDelta::ReasoningContent(reasoning) => { + if let ReasoningContentBlockDelta::Text(text) = reasoning { + let _ = + tx.send(Ok(StreamEvent::ThinkingDelta(text))).await; + } + } + _ => {} + } + } + } + ConverseStreamOutput::ContentBlockStop(_) => { + if current_tool.take().is_some() { + let _ = tx.send(Ok(StreamEvent::ToolUseEnd)).await; + } + } + ConverseStreamOutput::MessageStop(stop) => { + let reason = Some(format!("{:?}", stop.stop_reason())); + let _ = tx + .send(Ok(StreamEvent::MessageEnd { + stop_reason: reason, + })) + .await; + } + ConverseStreamOutput::Metadata(meta) => { + if let Some(usage) = meta.usage() { + let _ = tx + .send(Ok(StreamEvent::TokenUsage { + input_tokens: Some(usage.input_tokens() as u64), + output_tokens: Some(usage.output_tokens() as u64), + cache_read_input_tokens: None, + cache_creation_input_tokens: None, + })) + .await; + } + } + _ => {} + }, + Ok(None) => break, + Err(err) => { + let _ = tx + .send(Err(anyhow::anyhow!(Self::classify_error_message( + &Self::sdk_error_message(&err) + )))) + .await; + break; + } + } + } + }); + Ok(Box::pin(ReceiverStream::new(rx)) + as Pin< + Box> + Send>, + >) + } + + fn name(&self) -> &str { + "bedrock" + } + + fn model(&self) -> String { + self.model.read().unwrap_or_else(|p| p.into_inner()).clone() + } + + fn supports_image_input(&self) -> bool { + Self::model_info(&self.model()).supports_vision + } + + fn set_model(&self, model: &str) -> Result<()> { + let model = model.trim(); + let model = self + .profile_route_for_model(model) + .unwrap_or_else(|| model.to_string()); + *self.model.write().unwrap_or_else(|p| p.into_inner()) = model; + Ok(()) + } + + fn available_models(&self) -> Vec<&'static str> { + Self::known_models() + } + + fn available_models_display(&self) -> Vec { + self.all_display_models() + } + + fn available_models_for_switching(&self) -> Vec { + self.all_display_models() + } + + fn model_routes(&self) -> Vec { + let legacy_models = self + .legacy_models + .read() + .map(|guard| guard.clone()) + .unwrap_or_default(); + let profile_required_models = self + .profile_required_models + .read() + .map(|guard| guard.clone()) + .unwrap_or_default(); + self.all_display_models() + .into_iter() + .map(|model| { + let info = Self::model_info(&model); + let is_legacy = legacy_models.contains(&model); + let profile_foundation = Self::foundation_model_id_from_profile_id(&model); + let missing_required_profile = profile_foundation.is_none() + && profile_required_models.contains(&model) + && self.profile_route_for_model(&model).is_none(); + let mut features = Vec::new(); + if info.supports_tools { + features.push("tools"); + } else { + features.push("no tools"); + } + if info.supports_vision { + features.push("vision"); + } + if info.supports_reasoning { + features.push("reasoning"); + } + ModelRoute { + model: model.clone(), + provider: "AWS Bedrock".to_string(), + api_method: "bedrock".to_string(), + available: !is_legacy && !missing_required_profile, + detail: if is_legacy { + "legacy Bedrock model; choose an active model or inference profile" + .to_string() + } else if missing_required_profile { + "requires an inference profile; run /refresh-model-list or allow bedrock:ListInferenceProfiles" + .to_string() + } else { + let mut parts = Vec::new(); + if let Some(foundation) = profile_foundation { + parts.push(format!("inference profile for {}", foundation)); + } + parts.push(format!("context ~{} tokens", info.context_tokens)); + parts.push(format!("max output ~{}", info.max_output_tokens)); + parts.push(features.join(", ")); + format!( + "ConverseStream · {}", + parts + .into_iter() + .filter(|part| !part.trim().is_empty()) + .collect::>() + .join(" · ") + ) + }, + cheapness: Self::route_pricing(&model), + } + }) + .collect() + } + + async fn prefetch_models(&self) -> Result<()> { + self.refresh_catalog().await.map(|_| ()) + } + + async fn refresh_model_catalog(&self) -> Result { + let before_models = self.available_models_display(); + let before_routes = self.model_routes(); + self.refresh_catalog().await?; + let after_models = self.available_models_display(); + let after_routes = self.model_routes(); + Ok(summarize_model_catalog_refresh( + before_models, + after_models, + before_routes, + after_routes, + )) + } + + fn context_window(&self) -> usize { + Self::model_info(&self.model()).context_tokens + } + + fn supports_compaction(&self) -> bool { + true + } + + fn uses_jcode_compaction(&self) -> bool { + true + } + + fn fork(&self) -> Arc { + Arc::new(Self { + model: Arc::new(RwLock::new(self.model())), + fetched_models: self.fetched_models.clone(), + fetched_inference_profiles: self.fetched_inference_profiles.clone(), + profile_required_models: self.profile_required_models.clone(), + inference_profile_routes: self.inference_profile_routes.clone(), + legacy_models: self.legacy_models.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::{OsStr, OsString}; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + fn lock_test_env() -> MutexGuard<'static, ()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + jcode_core::env::set_var(key, value); + Self { key, previous } + } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + jcode_core::env::remove_var(key); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = self.previous.as_ref() { + jcode_core::env::set_var(self.key, value); + } else { + jcode_core::env::remove_var(self.key); + } + } + } + + #[test] + fn detects_env_credentials_requires_region_and_credential_hint() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let _removed = [ + "JCODE_BEDROCK_ENABLE", + API_KEY_ENV, + REGION_ENV, + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", + "JCODE_BEDROCK_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_CONFIG_FILE", + ] + .map(EnvVarGuard::remove); + jcode_core::env::set_var(REGION_ENV, "us-east-1"); + assert!(!BedrockProvider::has_credentials()); + jcode_core::env::set_var("AWS_PROFILE", "test"); + assert!(BedrockProvider::has_credentials()); + } + + #[test] + fn explicit_enable_marks_configured_for_instance_metadata_credentials() { + let _guard = lock_test_env(); + jcode_core::env::set_var("JCODE_BEDROCK_ENABLE", "1"); + assert!(BedrockProvider::has_credentials()); + jcode_core::env::remove_var("JCODE_BEDROCK_ENABLE"); + } + + #[test] + fn detects_bedrock_login_env_file_credentials() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + for key in [ + "JCODE_BEDROCK_ENABLE", + API_KEY_ENV, + REGION_ENV, + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", + "JCODE_BEDROCK_PROFILE", + "AWS_ACCESS_KEY_ID", + ] { + jcode_core::env::remove_var(key); + } + + assert!(!BedrockProvider::has_credentials()); + jcode_provider_env::save_env_value_to_env_file(API_KEY_ENV, ENV_FILE, Some("test-key")) + .unwrap(); + jcode_core::env::remove_var(API_KEY_ENV); + assert!(!BedrockProvider::has_credentials()); + + jcode_provider_env::save_env_value_to_env_file(REGION_ENV, ENV_FILE, Some("us-east-2")) + .unwrap(); + jcode_core::env::remove_var(REGION_ENV); + + assert_eq!( + BedrockProvider::configured_bearer_token().as_deref(), + Some("test-key") + ); + assert_eq!( + BedrockProvider::configured_region().as_deref(), + Some("us-east-2") + ); + assert!(BedrockProvider::has_credentials()); + } + + #[test] + fn switches_arbitrary_model_ids() { + let p = BedrockProvider::new(); + p.set_model("us.anthropic.claude-3-5-sonnet-20241022-v2:0") + .unwrap(); + assert_eq!(p.model(), "us.anthropic.claude-3-5-sonnet-20241022-v2:0"); + } + + #[test] + fn maps_profile_required_foundation_model_to_inference_profile() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + p.profile_required_models + .write() + .unwrap() + .insert("amazon.nova-2-lite-v1:0".to_string()); + p.inference_profile_routes.write().unwrap().insert( + "amazon.nova-2-lite-v1:0".to_string(), + "us.amazon.nova-2-lite-v1:0".to_string(), + ); + + p.set_model("amazon.nova-2-lite-v1:0").unwrap(); + + assert_eq!(p.model(), "us.amazon.nova-2-lite-v1:0"); + } + + #[test] + fn maps_foundation_model_from_stale_cached_profile_list() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_inference_profiles.write().unwrap() = vec![ + "global.amazon.nova-2-lite-v1:0".to_string(), + "us.amazon.nova-2-lite-v1:0".to_string(), + ]; + + p.set_model("amazon.nova-2-lite-v1:0").unwrap(); + + assert_eq!(p.model(), "us.amazon.nova-2-lite-v1:0"); + } + + #[test] + fn hides_profile_required_foundation_model_when_profile_route_exists() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; + *p.fetched_inference_profiles.write().unwrap() = + vec!["us.amazon.nova-2-lite-v1:0".to_string()]; + p.profile_required_models + .write() + .unwrap() + .insert("amazon.nova-2-lite-v1:0".to_string()); + p.inference_profile_routes.write().unwrap().insert( + "amazon.nova-2-lite-v1:0".to_string(), + "us.amazon.nova-2-lite-v1:0".to_string(), + ); + + let display = p.all_display_models(); + + assert!( + !display + .iter() + .any(|model| model == "amazon.nova-2-lite-v1:0") + ); + assert!( + display + .iter() + .any(|model| model == "us.amazon.nova-2-lite-v1:0") + ); + } + + #[test] + fn hides_foundation_model_when_profile_route_exists() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; + *p.fetched_inference_profiles.write().unwrap() = + vec!["us.amazon.nova-2-lite-v1:0".to_string()]; + p.inference_profile_routes.write().unwrap().insert( + "amazon.nova-2-lite-v1:0".to_string(), + "us.amazon.nova-2-lite-v1:0".to_string(), + ); + + let display = p.all_display_models(); + + assert!( + !display + .iter() + .any(|model| model == "amazon.nova-2-lite-v1:0") + ); + assert!( + display + .iter() + .any(|model| model == "us.amazon.nova-2-lite-v1:0") + ); + } + + #[test] + fn profile_required_foundation_model_without_profile_route_is_disabled() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_models.write().unwrap() = vec!["amazon.nova-2-lite-v1:0".to_string()]; + p.profile_required_models + .write() + .unwrap() + .insert("amazon.nova-2-lite-v1:0".to_string()); + + let route = p + .model_routes() + .into_iter() + .find(|route| route.model == "amazon.nova-2-lite-v1:0") + .expect("profile-required foundation model should be listed with a reason"); + + assert!(!route.available); + assert!(route.detail.contains("requires an inference profile")); + } + + #[test] + fn global_inference_profiles_use_foundation_capabilities_and_detail() { + let p = BedrockProvider::new(); + *p.fetched_inference_profiles.write().unwrap() = + vec!["global.amazon.nova-2-lite-v1:0".to_string()]; + + let route = p + .model_routes() + .into_iter() + .find(|route| route.model == "global.amazon.nova-2-lite-v1:0") + .expect("global inference profile should be listed"); + + assert!(route.available); + assert!( + route + .detail + .contains("inference profile for amazon.nova-2-lite-v1:0") + ); + assert!(route.detail.contains("tools")); + assert!(!route.detail.contains("no tools")); + } + + #[test] + fn ignores_persisted_bedrock_catalog_from_different_region() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + { + let _region = EnvVarGuard::set(REGION_ENV, "us-east-1"); + BedrockProvider::persist_catalog( + &["openai.gpt-oss-120b-1:0".to_string()], + &[], + &HashSet::new(), + &HashMap::new(), + &HashSet::new(), + ); + } + let _region = EnvVarGuard::set(REGION_ENV, "us-east-2"); + + let p = BedrockProvider::new(); + + assert!(p.fetched_models.read().unwrap().is_empty()); + } + + #[test] + fn prefers_region_inference_profile_over_global_profile() { + let _guard = lock_test_env(); + let _region = EnvVarGuard::set(REGION_ENV, "us-east-2"); + let mut routes = HashMap::new(); + + BedrockProvider::insert_preferred_profile_route( + &mut routes, + "amazon.nova-2-lite-v1:0", + "global.amazon.nova-2-lite-v1:0", + ); + BedrockProvider::insert_preferred_profile_route( + &mut routes, + "amazon.nova-2-lite-v1:0", + "us.amazon.nova-2-lite-v1:0", + ); + + assert_eq!( + routes.get("amazon.nova-2-lite-v1:0").map(String::as_str), + Some("us.amazon.nova-2-lite-v1:0") + ); + } + + #[test] + fn known_context_and_vision_capabilities() { + let p = BedrockProvider::new(); + p.set_model("anthropic.claude-3-5-sonnet-20241022-v2:0") + .unwrap(); + assert!(p.supports_image_input()); + assert_eq!(p.context_window(), 200_000); + p.set_model("amazon.nova-micro-v1:0").unwrap(); + assert!(!p.supports_image_input()); + assert_eq!(p.context_window(), 128_000); + } + + #[test] + fn known_no_tool_models_do_not_advertise_tools() { + assert!(!BedrockProvider::model_info("us.deepseek.r1-v1:0").supports_tools); + assert!(!BedrockProvider::model_info("deepseek.v3.2").supports_tools); + assert!( + !BedrockProvider::model_info("mistral.mistral-large-3-675b-instruct").supports_tools + ); + assert!(!BedrockProvider::model_info("openai.gpt-oss-120b-1:0").supports_tools); + assert!(BedrockProvider::model_info("us.amazon.nova-2-lite-v1:0").supports_tools); + assert!(BedrockProvider::model_info("us.anthropic.claude-sonnet-4-6").supports_tools); + } + + #[test] + fn error_classification_mentions_model_access() { + let message = BedrockProvider::classify_error_message( + "ValidationException: The provided model identifier is invalid", + ); + assert!(message.contains("model")); + assert!(message.contains("region")); + } + + #[test] + fn error_classification_mentions_legacy_models() { + let message = BedrockProvider::classify_error_message( + "Access denied. This Model is marked by provider as Legacy and you have not been actively using the model in the last 30 days", + ); + assert!(message.contains("legacy")); + assert!(message.contains("active")); + assert!(!message.starts_with("AWS IAM denied")); + } + + #[test] + fn tool_use_streaming_error_is_not_classified_as_legacy_sdk_type_name() { + let message = BedrockProvider::classify_error_message( + "ValidationException: This model doesn't support tool use in streaming mode. extensions_1x: {hyper_util::client::legacy::connect::http::HttpInfo}", + ); + assert!(message.contains("does not support tool use")); + assert!(!message.starts_with("This Bedrock model is marked as legacy")); + } + + #[test] + fn expired_sso_error_is_concise_and_actionable() { + let message = BedrockProvider::classify_error_message( + "ServiceError(ServiceError { source: AccessDeniedException(AccessDeniedException { message: Some(\"Bearer Token has expired\") }) })", + ); + assert_eq!( + message, + "AWS SSO/session credentials look expired. Run `aws sso login --profile ` and retry." + ); + } + + #[test] + fn missing_credentials_error_omits_sdk_blob() { + let message = BedrockProvider::classify_error_message( + "CredentialsNotLoaded: could not load credentials from any provider; extensions_1x: noisy sdk internals", + ); + assert!(message.contains("AWS credentials were not found")); + assert!(!message.contains("extensions_1x")); + } + + #[test] + fn legacy_model_route_is_unavailable_with_reason() { + let _guard = lock_test_env(); + let temp = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path().as_os_str()); + let p = BedrockProvider::new(); + *p.fetched_models.write().unwrap() = + vec!["anthropic.claude-3-haiku-20240307-v1:0".to_string()]; + p.legacy_models + .write() + .unwrap() + .insert("anthropic.claude-3-haiku-20240307-v1:0".to_string()); + + let route = p + .model_routes() + .into_iter() + .find(|route| route.model == "anthropic.claude-3-haiku-20240307-v1:0") + .expect("legacy route should be listed"); + + assert!(!route.available); + assert!(route.detail.contains("legacy")); + } + + #[tokio::test] + #[ignore = "requires AWS credentials and enabled Bedrock model access"] + async fn bedrock_live_smoke_test() { + if std::env::var("JCODE_BEDROCK_LIVE_TEST").ok().as_deref() != Some("1") { + return; + } + let provider = BedrockProvider::new(); + let output = provider + .complete_simple("say bedrock ok and nothing else", "") + .await + .expect("live Bedrock completion"); + assert!(output.to_ascii_lowercase().contains("bedrock ok")); + } +} From b2004f75d5c78306f5bb3a7bd80e0e6b66eb34f7 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:09:02 -0700 Subject: [PATCH 32/41] refactor(app-core): prune stale aws sdk deps --- Cargo.lock | 7 ------- crates/jcode-app-core/Cargo.toml | 7 ------- 2 files changed, 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80d995076..d526f108c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3400,13 +3400,6 @@ dependencies = [ "arboard", "async-stream", "async-trait", - "aws-config", - "aws-credential-types", - "aws-sdk-bedrock", - "aws-sdk-bedrockruntime", - "aws-sdk-sts", - "aws-smithy-types", - "aws-types", "base64 0.22.1", "bytes", "chrono", diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index e0617dc7f..4b8599489 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -133,13 +133,6 @@ tar = "0.4" tempfile = "3" agentgrep = { git = "https://github.com/1jehuang/agentgrep.git", tag = "v0.1.2" } qrcode = { version = "0.14.1", default-features = false } -aws-config = "1.8.16" -aws-credential-types = "1.2.14" -aws-sdk-bedrockruntime = "1.130.0" -aws-types = "1.3.15" -aws-smithy-types = "1.4.7" -aws-sdk-bedrock = "1.141.0" -aws-sdk-sts = "1.103.0" [features] default = ["pdf", "embeddings"] From 62cd56795cf48b23f410115070de94b6d901c65e Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:16:08 -0700 Subject: [PATCH 33/41] refactor(app-core): prune stale provider deps --- Cargo.lock | 4 ---- crates/jcode-app-core/Cargo.toml | 4 ---- 2 files changed, 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d526f108c..9ec80442c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3416,7 +3416,6 @@ dependencies = [ "jcode-agent-runtime", "jcode-ambient-types", "jcode-auth-types", - "jcode-azure-auth", "jcode-background-types", "jcode-base", "jcode-batch-types", @@ -3436,10 +3435,7 @@ dependencies = [ "jcode-plan", "jcode-protocol", "jcode-provider-core", - "jcode-provider-gemini", "jcode-provider-metadata", - "jcode-provider-openai", - "jcode-provider-openrouter", "jcode-selfdev-types", "jcode-session-types", "jcode-side-panel-types", diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index 4b8599489..6016d6e3e 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -69,15 +69,11 @@ hex = "0.4" url = "2" open = "5" # Open URLs in browser jcode-auth-types = { path = "../jcode-auth-types" } -jcode-azure-auth = { path = "../jcode-azure-auth" } jcode-agent-runtime = { path = "../jcode-agent-runtime" } jcode-ambient-types = { path = "../jcode-ambient-types" } jcode-notify-email = { path = "../jcode-notify-email" } jcode-provider-metadata = { path = "../jcode-provider-metadata" } jcode-provider-core = { path = "../jcode-provider-core" } -jcode-provider-openai = { path = "../jcode-provider-openai" } -jcode-provider-openrouter = { path = "../jcode-provider-openrouter" } -jcode-provider-gemini = { path = "../jcode-provider-gemini" } # NOTE: jcode-app-core does NOT depend on any jcode-tui-* crate. They were # unused dead dependency edges here (the TUI declares them itself). Removing # them stops a jcode-tui-* edit from cascading a recompile through app-core. From a79fd5368114795b59a3d608eebe402e1b720e79 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:39:57 -0700 Subject: [PATCH 34/41] tui: never auto-onboard self-dev/canary sessions The niri `jcode self-dev` hotkey (Alt+Semicolon) launches via run_self_dev, which jumps straight to run_tui_client and bypasses maybe_show_setup_hints. That means launch_count in setup_hints.json never advances, so the is_new_user_for_onboarding() heuristic (launch_count <= 5) treats every self-dev spawn as a brand-new user and re-runs onboarding each time. Guard both onboarding entry points on is_selfdev_canary_session(): canary sessions are developers working on jcode, not first-run users, so they should never auto-start the guided flow. /onboarding-preview still works for testing. --- .../src/tui/app/onboarding_flow_control.rs | 27 ++++++++++++++++++- .../src/tui/app/tests/onboarding_flow.rs | 21 +++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs b/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs index ec20f5439..15cba1a04 100644 --- a/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs +++ b/crates/jcode-tui/src/tui/app/onboarding_flow_control.rs @@ -40,7 +40,9 @@ impl App { self.onboarding_after_login(); return; } - if !self.onboarding_preview_mode && !self.is_new_user_for_onboarding() { + if !self.onboarding_preview_mode + && (self.is_selfdev_canary_session() || !self.is_new_user_for_onboarding()) + { return; } self.begin_onboarding_flow(); @@ -83,6 +85,14 @@ impl App { self.onboarding_startup_checked = true; return; } + // Self-dev / canary sessions are explicitly not first-run users: they are + // spawned by developers (e.g. the niri `jcode self-dev` hotkey) and that + // launch path never increments `launch_count`, so the new-user heuristic + // would otherwise re-onboard on every spawn. Skip onboarding for them. + if self.is_selfdev_canary_session() { + self.onboarding_startup_checked = true; + return; + } if !self.is_new_user_for_onboarding() { self.onboarding_startup_checked = true; return; @@ -111,6 +121,21 @@ impl App { .unwrap_or(true) } + /// Whether this is a self-dev / canary session. + /// + /// These are launched by developers working on jcode itself (for example the + /// niri `jcode self-dev` hotkey). That launch path bypasses + /// `maybe_show_setup_hints`, so `launch_count` never advances and the + /// new-user heuristic above would otherwise treat every spawn as a first run. + /// Such sessions should never auto-start the guided onboarding flow. + fn is_selfdev_canary_session(&self) -> bool { + if self.is_remote { + self.remote_is_canary.unwrap_or(self.session.is_canary) + } else { + self.session.is_canary + } + } + /// Begin the guided post-login flow. Called once auth becomes available on a /// fresh install (login/import completes). New users are not forced through a /// model picker; the default route is used and `/model` remains available. diff --git a/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs b/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs index f93ecfc9c..9bba6b32b 100644 --- a/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs +++ b/crates/jcode-tui/src/tui/app/tests/onboarding_flow.rs @@ -574,6 +574,27 @@ fn startup_check_is_noop_once_committed() { }); } +#[test] +fn startup_check_skips_selfdev_canary_session() { + with_temp_jcode_home(|| { + let mut app = create_test_app(); + app.onboarding_flow = None; + app.onboarding_startup_checked = false; + // Self-dev / canary sessions (e.g. the niri `jcode self-dev` hotkey) take + // a launch path that never bumps `launch_count`, so without this guard the + // new-user heuristic would re-onboard on every spawn. + app.session.is_canary = true; + + app.maybe_begin_onboarding_flow_on_startup(); + + assert!(app.onboarding_startup_checked); + assert!( + app.onboarding_flow.is_none(), + "self-dev/canary sessions must never auto-start onboarding" + ); + }); +} + #[test] fn model_validation_success_appends_single_ready_line() { let mut app = create_test_app(); From 55369dc68f15a519c126f25001febe017c2963a6 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:41:00 -0700 Subject: [PATCH 35/41] fix(openai): don't drop response.completed frames that mention websocket fallback A naive substring check (is_websocket_fallback_notice) treated any SSE payload containing 'falling back from websockets to https transport' as a plain-text transport control frame and dropped it. When the model edited source files that mention that phrase, the text rode along inside structured response.completed / response.output_item.done / response.function_call_arguments.done events, all of which were silently dropped. MessageEnd was never emitted, surfacing as 'OpenAI HTTPS stream ended before message completion marker' and forcing repeated retries. Structured response.*/error events are now never reinterpreted as plain transport control frames. Same guard applied to the 'stream disconnected before completion' check. Adds regression tests. --- crates/jcode-base/src/provider/openai.rs | 7 +- .../jcode-base/src/provider/openai/stream.rs | 92 ++++++++++++++++++- .../src/provider/openai/websocket_health.rs | 17 ++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/crates/jcode-base/src/provider/openai.rs b/crates/jcode-base/src/provider/openai.rs index 596234d58..a5b132b7d 100644 --- a/crates/jcode-base/src/provider/openai.rs +++ b/crates/jcode-base/src/provider/openai.rs @@ -1029,9 +1029,10 @@ use self::websocket_health::{ set_websocket_cooldown, websocket_cooldown_for_streak, websocket_remaining_timeout_secs, }; use self::websocket_health::{ - classify_websocket_fallback_reason, is_stream_activity_event, is_websocket_activity_payload, - is_websocket_fallback_notice, is_websocket_first_activity_payload, record_websocket_fallback, - record_websocket_success, summarize_websocket_fallback_reason, websocket_activity_timeout_kind, + classify_websocket_fallback_reason, is_stream_activity_event, is_structured_response_event, + is_websocket_activity_payload, is_websocket_fallback_notice, + is_websocket_first_activity_payload, record_websocket_fallback, record_websocket_success, + summarize_websocket_fallback_reason, websocket_activity_timeout_kind, websocket_cooldown_remaining, websocket_next_activity_timeout_secs, }; diff --git a/crates/jcode-base/src/provider/openai/stream.rs b/crates/jcode-base/src/provider/openai/stream.rs index edb15def1..56c5424a0 100644 --- a/crates/jcode-base/src/provider/openai/stream.rs +++ b/crates/jcode-base/src/provider/openai/stream.rs @@ -1,6 +1,6 @@ use super::{ FALLBACK_TOOL_CALL_COUNTER, NORMALIZED_NULL_TOOL_ARGUMENTS, RECOVERED_TEXT_WRAPPED_TOOL_CALLS, - extract_error_with_retry, is_websocket_fallback_notice, + extract_error_with_retry, is_structured_response_event, is_websocket_fallback_notice, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; @@ -223,6 +223,7 @@ pub(super) fn parse_openai_response_event( if data .to_lowercase() .contains("stream disconnected before completion") + && !is_structured_response_event(data) { return Some(StreamEvent::Error { message: data.to_string(), @@ -816,4 +817,93 @@ mod tests { assert!(completed_tool_items.is_empty()); assert!(pending.is_empty()); } + + #[test] + fn response_completed_emits_message_end_even_when_payload_mentions_fallback() { + // Regression: when the model edits source that mentions the websocket + // fallback phrase, that text rides along inside structured events. A + // `response.completed` frame containing the phrase must still produce a + // MessageEnd, otherwise the stream "ends before the completion marker". + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let payload = serde_json::json!({ + "type": "response.completed", + "response": { + "status": "completed", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "falling back from websockets to https transport" + }] + }] + } + }) + .to_string(); + + let event = parse_openai_response_event( + &payload, + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!( + matches!(event, Some(StreamEvent::MessageEnd { .. })), + "expected MessageEnd, got {event:?}" + ); + } + + #[test] + fn function_call_arguments_with_fallback_phrase_still_emit_tool_call() { + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let payload = serde_json::json!({ + "type": "response.function_call_arguments.done", + "item_id": "fc_1", + "call_id": "call_1", + "name": "bash", + "arguments": "{\"command\":\"echo falling back from websockets to https transport\"}" + }) + .to_string(); + + let event = parse_openai_response_event( + &payload, + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!( + matches!(event, Some(StreamEvent::ToolUseStart { .. })), + "expected ToolUseStart, got {event:?}" + ); + } + + #[test] + fn plain_text_fallback_notice_is_still_dropped() { + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let event = parse_openai_response_event( + "falling back from websockets to https transport", + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!(event.is_none()); + } } diff --git a/crates/jcode-base/src/provider/openai/websocket_health.rs b/crates/jcode-base/src/provider/openai/websocket_health.rs index 7361d54ca..92f7beb15 100644 --- a/crates/jcode-base/src/provider/openai/websocket_health.rs +++ b/crates/jcode-base/src/provider/openai/websocket_health.rs @@ -35,6 +35,15 @@ impl WebsocketFallbackReason { } pub(super) fn is_websocket_fallback_notice(data: &str) -> bool { + // The proxy injects the fallback notice as a plain-text control frame, not + // a structured Responses API event. A legitimate `response.*`/`error` + // event can legitimately *contain* this phrase (for example inside + // tool-call arguments when the model is editing source that mentions + // websocket fallback), so a structured event must never be reinterpreted + // as a transport control frame. + if is_structured_response_event(data) { + return false; + } data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE) } @@ -42,6 +51,14 @@ pub(super) fn is_stream_activity_event(_event: &StreamEvent) -> bool { true } +/// Returns true when `data` parses as a structured Responses API stream event +/// (a JSON object whose `type` is a `response.*` event or a top-level `error`). +/// These frames carry model output and must be parsed as protocol events even +/// if their content happens to contain transport-control phrases. +pub(super) fn is_structured_response_event(data: &str) -> bool { + is_websocket_activity_payload(data) +} + pub(super) fn is_websocket_activity_payload(data: &str) -> bool { let Ok(value) = serde_json::from_str::(data) else { return false; From d50becc6b8910a5b4bdd105e52fc0f23119d5de4 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:46:17 -0700 Subject: [PATCH 36/41] tui: add OSC 52 clipboard fallback for SSH/Docker (#327) --- crates/jcode-tui/src/tui/app/helpers.rs | 28 +++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/helpers.rs b/crates/jcode-tui/src/tui/app/helpers.rs index 8c5764aac..4b3d5ccb3 100644 --- a/crates/jcode-tui/src/tui/app/helpers.rs +++ b/crates/jcode-tui/src/tui/app/helpers.rs @@ -248,7 +248,8 @@ pub(super) fn format_tokens(tokens: u64) -> String { } } -/// Copy text to clipboard, trying wl-copy first (Wayland), then arboard as fallback. +/// Copy text to clipboard, trying wl-copy first (Wayland), then OSC 52 (works +/// over SSH / Docker / tmux), then arboard as a final fallback. pub(super) fn copy_to_clipboard(text: &str) -> bool { if let Ok(mut child) = std::process::Command::new("wl-copy") .stdin(std::process::Stdio::piped()) @@ -261,14 +262,37 @@ pub(super) fn copy_to_clipboard(text: &str) -> bool { && stdin.write_all(text.as_bytes()).is_ok() { drop(child.stdin.take()); - return child.wait().map(|s| s.success()).unwrap_or(false); + if child.wait().map(|s| s.success()).unwrap_or(false) { + return true; + } } } + if copy_to_clipboard_osc52(text) { + return true; + } arboard::Clipboard::new() .and_then(|mut cb| cb.set_text(text.to_string())) .is_ok() } +/// Copy to clipboard using the OSC 52 terminal escape sequence. This asks the +/// terminal emulator to set the system clipboard without needing a local +/// display server, making it work over SSH, inside Docker, and under tmux +/// (with `set -g set-clipboard on`). Returns false if stdout is not a TTY. +fn copy_to_clipboard_osc52(text: &str) -> bool { + use base64::Engine as _; + use std::io::{IsTerminal, Write}; + + let mut out = std::io::stdout(); + if !out.is_terminal() { + return false; + } + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + // OSC 52: ESC ] 52 ; c ; BEL + let seq = format!("\x1b]52;c;{}\x07", encoded); + out.write_all(seq.as_bytes()).is_ok() && out.flush().is_ok() +} + pub(super) fn effort_display_label(effort: &str) -> &str { match effort { "max" => "Max", From 8356469a7d7bd439cd816bcb9c3b9ee7f57fd127 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:58:32 -0700 Subject: [PATCH 37/41] perf(onboarding): cache external-CLI continuation-prompt scan The onboarding welcome screen animates a donut, so it redraws at animation FPS. Each redraw calls suggestion_prompts() (twice: once via onboarding_welcome_active() and again via welcome_body_lines()), which calls latest_external_cli_continuation_prompt(). That function reads and JSON-parses the newest 32 transcripts from ~/.codex/sessions and ~/.claude/projects on every call. For users with large external histories this is very expensive: measured ~387ms per call against a ~1.2GB ~/.codex/sessions tree (parsing ~17k JSON lines / ~39MB per frame), running ~2x per frame -> ~0.7s of blocking work per onboarding frame, which is the source of the onboarding lag. Wrap the scan in a 30s TTL cache. The cached path drops repeated calls from ~387ms to ~0.0001ms. Adds an ignored timing test (onboarding_suggestion_scan_cost) documenting cold vs warm cost. --- .../src/tui/app/state_ui_input_helpers.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index e019b9add..d45ea60d2 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -1425,7 +1425,45 @@ struct ExternalCliSuggestionCandidate { context: Option, } +/// How long a scan of the external-CLI session directories is reused before we +/// re-scan. The onboarding welcome screen animates a donut, so it redraws at +/// animation FPS and calls [`latest_external_cli_continuation_prompt`] multiple +/// times per frame. Scanning `~/.codex/sessions` / `~/.claude/projects` (reading +/// and JSON-parsing the newest transcripts) can cost hundreds of milliseconds +/// for users with large histories, which would otherwise make first-run +/// onboarding extremely laggy. A short TTL keeps the suggestion fresh while +/// reducing the cost to a single scan per window. +const EXTERNAL_CLI_PROMPT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30); + +/// Cached result of the external-CLI continuation-prompt scan, with the time it +/// was computed. `None` value means "scanned, but nothing found". +static EXTERNAL_CLI_PROMPT_CACHE: std::sync::LazyLock< + std::sync::RwLock, std::time::Instant)>>, +> = std::sync::LazyLock::new(|| std::sync::RwLock::new(None)); + +/// Cached front-end for [`latest_external_cli_continuation_prompt_uncached`]. +/// +/// See [`EXTERNAL_CLI_PROMPT_CACHE_TTL`] for why this is cached: the uncached +/// scan reads and parses the newest external transcripts, which is expensive for +/// large histories and would otherwise run several times per onboarding frame. fn latest_external_cli_continuation_prompt() -> Option { + if let Ok(cache) = EXTERNAL_CLI_PROMPT_CACHE.read() + && let Some((ref value, ref when)) = *cache + && when.elapsed() < EXTERNAL_CLI_PROMPT_CACHE_TTL + { + return value.clone(); + } + + let value = latest_external_cli_continuation_prompt_uncached(); + + if let Ok(mut cache) = EXTERNAL_CLI_PROMPT_CACHE.write() { + *cache = Some((value.clone(), std::time::Instant::now())); + } + + value +} + +fn latest_external_cli_continuation_prompt_uncached() -> Option { let home = std::env::var_os("HOME").map(PathBuf::from)?; let mut candidates = Vec::new(); candidates.extend(latest_jsonl_suggestion_candidates( @@ -1645,6 +1683,40 @@ mod external_cli_suggestion_tests { use super::*; use std::io::Write; + /// Faithful, real-home measurement of the per-frame onboarding cost. + /// Ignored by default (depends on local ~/.codex and ~/.claude contents). + /// Run with: + /// cargo test -p jcode-tui --lib onboarding_suggestion_scan_cost -- --ignored --nocapture + #[test] + #[ignore] + fn onboarding_suggestion_scan_cost() { + use std::time::Instant; + + // Cold: the uncached scan that reads + JSON-parses the newest external + // transcripts. This is the work that used to run several times per frame. + let cold_start = Instant::now(); + let cold = latest_external_cli_continuation_prompt_uncached(); + let cold_ms = cold_start.elapsed().as_secs_f64() * 1000.0; + + // Warm: the cached front-end the onboarding screen actually calls. Prime + // the cache once, then measure repeated calls (as a redrawing frame does). + let _ = latest_external_cli_continuation_prompt(); + let runs = 1000; + let warm_start = Instant::now(); + let mut warm = None; + for _ in 0..runs { + warm = latest_external_cli_continuation_prompt(); + } + let warm_ms = warm_start.elapsed().as_secs_f64() * 1000.0 / runs as f64; + + eprintln!( + "external-cli continuation prompt: cold(uncached)={cold_ms:.1} ms, \ + warm(cached, avg of {runs})={warm_ms:.4} ms; cold_some={}, warm_some={}", + cold.is_some(), + warm.is_some() + ); + } + #[test] fn parses_claude_code_jsonl_with_session_path_and_context() { let temp = tempfile::tempdir().expect("tempdir"); From 184341ff60973901fbf60178cbe8488392c8b4fe Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:01:28 -0700 Subject: [PATCH 38/41] refactor(provider): move OpenAI stream parser to leaf crate --- Cargo.lock | 8 + crates/jcode-base/src/provider/openai.rs | 15 +- .../jcode-base/src/provider/openai/stream.rs | 911 +--------------- .../src/provider/openai_stream_runtime.rs | 69 -- crates/jcode-provider-openai/Cargo.toml | 8 + crates/jcode-provider-openai/src/lib.rs | 1 + crates/jcode-provider-openai/src/stream.rs | 993 ++++++++++++++++++ 7 files changed, 1019 insertions(+), 986 deletions(-) create mode 100644 crates/jcode-provider-openai/src/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 9ec80442c..874bc0bb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3888,8 +3888,16 @@ dependencies = [ name = "jcode-provider-openai" version = "0.1.0" dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "futures", + "jcode-core", + "jcode-logging", "jcode-message-types", "jcode-provider-core", + "reqwest 0.12.28", + "serde", "serde_json", ] diff --git a/crates/jcode-base/src/provider/openai.rs b/crates/jcode-base/src/provider/openai.rs index a5b132b7d..183f0a8e0 100644 --- a/crates/jcode-base/src/provider/openai.rs +++ b/crates/jcode-base/src/provider/openai.rs @@ -13,7 +13,6 @@ use reqwest::{Client, StatusCode}; use serde_json::Value; use std::collections::{HashMap, HashSet, VecDeque}; use std::panic::AssertUnwindSafe; -use std::sync::atomic::AtomicU64; use std::sync::{Arc, LazyLock, RwLock as StdRwLock}; use std::time::{Duration, Instant}; use tokio::net::TcpStream; @@ -98,9 +97,6 @@ const WEBSOCKET_MODEL_COOLDOWN_BASE_SECS: u64 = 60; /// Maximum websocket cooldown after repeated fallback streaks. const WEBSOCKET_MODEL_COOLDOWN_MAX_SECS: u64 = 600; const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 32_768; -static FALLBACK_TOOL_CALL_COUNTER: AtomicU64 = AtomicU64::new(1); -static RECOVERED_TEXT_WRAPPED_TOOL_CALLS: AtomicU64 = AtomicU64::new(0); -static NORMALIZED_NULL_TOOL_ARGUMENTS: AtomicU64 = AtomicU64::new(0); static WEBSOCKET_COOLDOWNS: LazyLock>>> = LazyLock::new(|| Arc::new(RwLock::new(HashMap::new()))); static WEBSOCKET_FAILURE_STREAKS: LazyLock>>> = @@ -1008,9 +1004,7 @@ impl OpenAIProvider { mod stream; -use self::openai_stream_runtime::{ - PersistentWsResult, extract_error_with_retry, is_retryable_error, openai_access_token, -}; +use self::openai_stream_runtime::{PersistentWsResult, is_retryable_error, openai_access_token}; use self::stream::{OpenAIResponsesStream, parse_openai_response_event}; #[cfg(test)] @@ -1029,10 +1023,9 @@ use self::websocket_health::{ set_websocket_cooldown, websocket_cooldown_for_streak, websocket_remaining_timeout_secs, }; use self::websocket_health::{ - classify_websocket_fallback_reason, is_stream_activity_event, is_structured_response_event, - is_websocket_activity_payload, is_websocket_fallback_notice, - is_websocket_first_activity_payload, record_websocket_fallback, record_websocket_success, - summarize_websocket_fallback_reason, websocket_activity_timeout_kind, + classify_websocket_fallback_reason, is_stream_activity_event, is_websocket_activity_payload, + is_websocket_fallback_notice, is_websocket_first_activity_payload, record_websocket_fallback, + record_websocket_success, summarize_websocket_fallback_reason, websocket_activity_timeout_kind, websocket_cooldown_remaining, websocket_next_activity_timeout_secs, }; diff --git a/crates/jcode-base/src/provider/openai/stream.rs b/crates/jcode-base/src/provider/openai/stream.rs index 56c5424a0..00212c5a7 100644 --- a/crates/jcode-base/src/provider/openai/stream.rs +++ b/crates/jcode-base/src/provider/openai/stream.rs @@ -1,909 +1,8 @@ -use super::{ - FALLBACK_TOOL_CALL_COUNTER, NORMALIZED_NULL_TOOL_ARGUMENTS, RECOVERED_TEXT_WRAPPED_TOOL_CALLS, - extract_error_with_retry, is_structured_response_event, is_websocket_fallback_notice, +pub(super) use jcode_provider_openai::stream::{ + OpenAIResponsesStream, parse_openai_response_event, }; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; - -fn truncated_stream_payload_context(data: &str) -> String { - crate::util::truncate_str(&data.trim().replace("\n", "\\n"), 240).to_string() -} -use crate::message::StreamEvent; -use anyhow::Result; -use bytes::Bytes; -use futures::Stream; -use serde::Deserialize; -use serde_json::Value; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::pin::Pin; -use std::sync::atomic::Ordering; -use std::task::{Context as TaskContext, Poll}; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub(super) fn parse_text_wrapped_tool_call(text: &str) -> Option<(String, String, String, String)> { - let marker = "to=functions."; - let marker_idx = text.find(marker)?; - let after_marker = &text[marker_idx + marker.len()..]; - - let mut tool_name_end = 0usize; - for (idx, ch) in after_marker.char_indices() { - if ch.is_ascii_alphanumeric() || ch == '_' { - tool_name_end = idx + ch.len_utf8(); - } else { - break; - } - } - if tool_name_end == 0 { - return None; - } - - let tool_name = after_marker[..tool_name_end].to_string(); - let remaining = &after_marker[tool_name_end..]; - let mut fallback: Option<(String, String, String, String)> = None; - for (brace_idx, ch) in remaining.char_indices() { - if ch != '{' { - continue; - } - let slice = &remaining[brace_idx..]; - let mut stream = serde_json::Deserializer::from_str(slice).into_iter::(); - let parsed = match stream.next() { - Some(Ok(value)) => value, - Some(Err(_)) => continue, - None => continue, - }; - let consumed = stream.byte_offset(); - if !parsed.is_object() { - continue; - } - - let prefix = text[..marker_idx].trim_end().to_string(); - let suffix = remaining[brace_idx + consumed..].trim().to_string(); - let args = serde_json::to_string(&parsed).ok()?; - if suffix.is_empty() { - return Some((prefix, tool_name.clone(), args, suffix)); - } - if fallback.is_none() { - fallback = Some((prefix, tool_name.clone(), args, suffix)); - } - } - - fallback -} - -fn stream_text_or_recovered_tool_call( - text: &str, - pending: &mut VecDeque, -) -> Option { - if text.is_empty() { - return None; - } - - if let Some((prefix, tool_name, arguments, suffix)) = parse_text_wrapped_tool_call(text) { - let total = RECOVERED_TEXT_WRAPPED_TOOL_CALLS.fetch_add(1, Ordering::Relaxed) + 1; - crate::logging::warn(&format!( - "[openai] Recovered text-wrapped tool call for '{}' (total={})", - tool_name, total - )); - let suffix = sanitize_recovered_tool_suffix(&suffix); - if !prefix.is_empty() { - pending.push_back(StreamEvent::TextDelta(prefix)); - } - pending.push_back(StreamEvent::ToolUseStart { - id: format!( - "fallback_text_call_{}", - FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) - ), - name: tool_name, - }); - pending.push_back(StreamEvent::ToolInputDelta(arguments)); - pending.push_back(StreamEvent::ToolUseEnd); - if !suffix.is_empty() { - pending.push_back(StreamEvent::TextDelta(suffix)); - } - return pending.pop_front(); - } - - Some(StreamEvent::TextDelta(text.to_string())) -} - -fn sanitize_recovered_tool_suffix(suffix: &str) -> String { - let trimmed = suffix.trim(); - if trimmed.is_empty() { - return String::new(); - } - - let normalized = trimmed.trim_start_matches('"'); - - if normalized.starts_with(",\"item_id\"") - || normalized.starts_with(",\"output_index\"") - || normalized.starts_with(",\"sequence_number\"") - || normalized.starts_with(",\"call_id\"") - || normalized.starts_with(",\"type\":\"response.") - || (normalized.starts_with(',') - && normalized.contains("\"item_id\"") - && (normalized.contains("\"output_index\"") - || normalized.contains("\"sequence_number\""))) - { - return String::new(); - } - - suffix.to_string() -} - -#[derive(Deserialize, Debug)] -struct ResponseSseEvent { - #[serde(rename = "type")] - kind: String, - item: Option, - delta: Option, - item_id: Option, - call_id: Option, - name: Option, - arguments: Option, - response: Option, - error: Option, -} - -#[derive(Debug, Clone, Default)] -pub(super) struct StreamingToolCallState { - call_id: Option, - name: Option, - arguments: String, -} - -fn normalize_openai_tool_arguments(raw_arguments: String) -> String { - let trimmed = raw_arguments.trim(); - if trimmed.is_empty() || trimmed == "null" { - let total = NORMALIZED_NULL_TOOL_ARGUMENTS.fetch_add(1, Ordering::Relaxed) + 1; - crate::logging::warn(&format!( - "[openai] Normalized empty/null tool arguments to empty object (total={})", - total - )); - "{}".to_string() - } else { - raw_arguments - } -} - -fn streaming_tool_item_id(item: &Value) -> Option { - item.get("id") - .and_then(|v| v.as_str()) - .or_else(|| item.get("item_id").and_then(|v| v.as_str())) - .map(|id| id.to_string()) -} - -fn stream_tool_call_from_state( - item_id: Option, - mut state: StreamingToolCallState, - pending: &mut VecDeque, -) -> Option { - let tool_name = state.name.take().filter(|name| !name.is_empty())?; - let raw_call_id = state - .call_id - .take() - .filter(|id| !id.is_empty()) - .or(item_id) - .unwrap_or_else(|| { - format!( - "fallback_text_call_{}", - FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) - ) - }); - let call_id = crate::message::sanitize_tool_id(&raw_call_id); - let arguments = normalize_openai_tool_arguments(if state.arguments.is_empty() { - "{}".to_string() - } else { - state.arguments - }); - - pending.push_back(StreamEvent::ToolUseStart { - id: call_id, - name: tool_name, - }); - pending.push_back(StreamEvent::ToolInputDelta(arguments)); - pending.push_back(StreamEvent::ToolUseEnd); - pending.pop_front() -} - -pub(super) fn parse_openai_response_event( - data: &str, - saw_text_delta: &mut bool, - streaming_tool_calls: &mut HashMap, - completed_tool_items: &mut HashSet, - pending: &mut VecDeque, -) -> Option { - if data == "[DONE]" { - return Some(StreamEvent::MessageEnd { stop_reason: None }); - } - - if is_websocket_fallback_notice(data) { - crate::logging::warn(&format!("OpenAI stream transport notice: {}", data.trim())); - return None; - } - - if data - .to_lowercase() - .contains("stream disconnected before completion") - && !is_structured_response_event(data) - { - return Some(StreamEvent::Error { - message: data.to_string(), - retry_after_secs: None, - }); - } - - let event: ResponseSseEvent = match serde_json::from_str(data) { - Ok(parsed) => parsed, - Err(error) => { - crate::logging::warn(&format!( - "OpenAI SSE JSON parse failed: {} payload={}", - error, - truncated_stream_payload_context(data) - )); - return None; - } - }; - - match event.kind.as_str() { - "response.output_text.delta" => { - if let Some(delta) = event.delta { - *saw_text_delta = true; - return stream_text_or_recovered_tool_call(&delta, pending); - } - } - "response.reasoning.delta" | "response.reasoning_summary_text.delta" => { - if let Some(delta) = event.delta { - return Some(StreamEvent::ThinkingDelta(delta)); - } - } - "response.reasoning.done" | "response.output_item.added" => { - if let Some(item) = &event.item { - if item.get("type").and_then(|v| v.as_str()) == Some("reasoning") { - return Some(StreamEvent::ThinkingStart); - } - if matches!( - item.get("type").and_then(|v| v.as_str()), - Some("function_call") | Some("custom_tool_call") - ) && let Some(item_id) = streaming_tool_item_id(item) - { - let state = streaming_tool_calls.entry(item_id).or_default(); - state.call_id = item - .get("call_id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| state.call_id.clone()); - state.name = item - .get("name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .or_else(|| state.name.clone()); - if let Some(arguments) = item - .get("arguments") - .and_then(|v| v.as_str()) - .or_else(|| item.get("input").and_then(|v| v.as_str())) - { - state.arguments = arguments.to_string(); - } else if let Some(input) = item.get("input") - && (input.is_object() || input.is_array()) - { - state.arguments = input.to_string(); - } - } - } - } - "response.function_call_arguments.delta" => { - if let Some(item_id) = event.item_id { - let state = streaming_tool_calls.entry(item_id).or_default(); - if let Some(call_id) = event.call_id { - state.call_id = Some(call_id); - } - if let Some(name) = event.name { - state.name = Some(name); - } - if let Some(delta) = event.delta { - state.arguments.push_str(&delta); - } - } - } - "response.function_call_arguments.done" => { - if let Some(item_id) = event.item_id { - let mut state = streaming_tool_calls.remove(&item_id).unwrap_or_default(); - if let Some(call_id) = event.call_id { - state.call_id = Some(call_id); - } - if let Some(name) = event.name { - state.name = Some(name); - } - if let Some(arguments) = event.arguments { - state.arguments = arguments; - } - if let Some(tool_event) = - stream_tool_call_from_state(Some(item_id.clone()), state.clone(), pending) - { - completed_tool_items.insert(item_id); - return Some(tool_event); - } - streaming_tool_calls.insert(item_id, state); - } - } - "response.output_item.done" => { - if let Some(item) = event.item { - if let Some(item_id) = streaming_tool_item_id(&item) - && completed_tool_items.contains(&item_id) - && matches!( - item.get("type").and_then(|v| v.as_str()), - Some("function_call") | Some("custom_tool_call") - ) - { - completed_tool_items.remove(&item_id); - return None; - } - if let Some(event) = handle_openai_output_item(item, saw_text_delta, pending) { - return Some(event); - } - } - } - "response.incomplete" => { - let stop_reason = event - .response - .as_ref() - .and_then(extract_stop_reason_from_response) - .or_else(|| Some("incomplete".to_string())); - if let Some(response) = event.response - && let Some(usage_event) = extract_usage_from_response(&response) - { - pending.push_back(usage_event); - } - pending.push_back(StreamEvent::MessageEnd { stop_reason }); - return pending.pop_front(); - } - "response.completed" => { - let stop_reason = event - .response - .as_ref() - .and_then(extract_stop_reason_from_response); - if let Some(response) = event.response - && let Some(usage_event) = extract_usage_from_response(&response) - { - pending.push_back(usage_event); - } - pending.push_back(StreamEvent::MessageEnd { stop_reason }); - return pending.pop_front(); - } - "response.failed" | "response.error" | "error" => { - crate::logging::warn(&format!( - "OpenAI stream error event (type={}): response={:?}, error={:?}", - event.kind, event.response, event.error - )); - let (message, retry_after_secs) = - extract_error_with_retry(&event.response, &event.error); - return Some(StreamEvent::Error { - message, - retry_after_secs, - }); - } - _ => {} - } - - None -} - -fn extract_last_assistant_message_phase(response: &Value) -> Option { - let output = response.get("output")?.as_array()?; - output.iter().rev().find_map(|item| { - if item.get("type").and_then(|v| v.as_str()) != Some("message") { - return None; - } - if item.get("role").and_then(|v| v.as_str()) != Some("assistant") { - return None; - } - item.get("phase") - .and_then(|v| v.as_str()) - .map(|phase| phase.to_string()) - }) -} - -fn extract_stop_reason_from_response(response: &Value) -> Option { - let status = response.get("status").and_then(|v| v.as_str()); - if status == Some("completed") { - if extract_last_assistant_message_phase(response).as_deref() == Some("commentary") { - return Some("commentary".to_string()); - } - return None; - } - - let incomplete_reason = response - .get("incomplete_details") - .and_then(|v| v.get("reason")) - .and_then(|v| v.as_str()); - - if let Some(reason) = incomplete_reason { - return Some(reason.to_string()); - } - - status - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) -} - -pub(super) fn handle_openai_output_item( - item: Value, - saw_text_delta: &mut bool, - pending: &mut VecDeque, -) -> Option { - let item_type = item.get("type")?.as_str()?; - match item_type { - "compaction" => { - let encrypted_content = item - .get("encrypted_content") - .and_then(|v| v.as_str()) - .map(|value| value.to_string())?; - return Some(StreamEvent::Compaction { - trigger: "openai_native_auto".to_string(), - pre_tokens: None, - openai_encrypted_content: Some(encrypted_content), - }); - } - "function_call" | "custom_tool_call" => { - let call_id = item - .get("call_id") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let name = item - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let raw_arguments = item - .get("arguments") - .and_then(|v| v.as_str().map(|s| s.to_string())) - .or_else(|| { - item.get("input").and_then(|v| { - if v.is_object() || v.is_array() { - Some(v.to_string()) - } else { - v.as_str().map(|s| s.to_string()) - } - }) - }) - .unwrap_or_else(|| "{}".to_string()); - let arguments = normalize_openai_tool_arguments(raw_arguments); - - pending.push_back(StreamEvent::ToolUseStart { - id: call_id.clone(), - name, - }); - pending.push_back(StreamEvent::ToolInputDelta(arguments)); - pending.push_back(StreamEvent::ToolUseEnd); - return pending.pop_front(); - } - "image_generation_call" => { - if let Some(event) = handle_openai_image_generation_item(&item, pending) { - return Some(event); - } - } - "message" => { - if *saw_text_delta { - return None; - } - let mut text = String::new(); - if let Some(content) = item.get("content").and_then(|v| v.as_array()) { - for entry in content { - let entry_type = entry.get("type").and_then(|v| v.as_str()); - if matches!(entry_type, Some("output_text") | Some("text")) - && let Some(t) = entry.get("text").and_then(|v| v.as_str()) - { - text.push_str(t); - } - } - } - return stream_text_or_recovered_tool_call(&text, pending); - } - "reasoning" => { - let id = item - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let mut summary = Vec::new(); - if let Some(summary_arr) = item.get("summary").and_then(|v| v.as_array()) { - for summary_item in summary_arr { - if summary_item.get("type").and_then(|v| v.as_str()) == Some("summary_text") - && let Some(text) = summary_item.get("text").and_then(|v| v.as_str()) - { - summary.push(text.to_string()); - } - } - } - let encrypted_content = item - .get("encrypted_content") - .and_then(|v| v.as_str()) - .map(|value| value.to_string()); - let status = item - .get("status") - .and_then(|v| v.as_str()) - .map(|value| value.to_string()); - - if !id.is_empty() && (encrypted_content.is_some() || !summary.is_empty()) { - pending.push_back(StreamEvent::OpenAIReasoning { - id, - summary: summary.clone(), - encrypted_content, - status, - }); - } - - if !summary.is_empty() { - pending.push_back(StreamEvent::ThinkingStart); - pending.push_back(StreamEvent::ThinkingDelta(summary.join("\n"))); - pending.push_back(StreamEvent::ThinkingEnd); - return pending.pop_front(); - } - return pending.pop_front(); - } - _ => {} - } - - None -} - -fn handle_openai_image_generation_item( - item: &Value, - pending: &mut VecDeque, -) -> Option { - let result_b64 = item.get("result")?.as_str()?; - if result_b64.is_empty() { - return None; - } - - let image_bytes = match BASE64_STANDARD.decode(result_b64) { - Ok(bytes) => bytes, - Err(err) => { - crate::logging::warn(&format!( - "OpenAI image_generation_call returned invalid base64: {}", - err - )); - return Some(StreamEvent::TextDelta( - "\n[Generated image received, but Jcode could not decode it.]\n".to_string(), - )); - } - }; - - let output_format = item - .get("output_format") - .and_then(|v| v.as_str()) - .unwrap_or("png"); - let extension = match output_format { - "jpeg" | "jpg" => "jpg", - "webp" => "webp", - _ => "png", - }; - let item_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("image"); - let safe_id: String = item_id - .chars() - .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '-') - .take(80) - .collect(); - let safe_id = if safe_id.is_empty() { - "image".to_string() - } else { - safe_id - }; - let timestamp_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis()) - .unwrap_or_default(); - let dir = std::env::current_dir() - .unwrap_or_else(|_| std::env::temp_dir()) - .join(".jcode") - .join("generated-images"); - if let Err(err) = std::fs::create_dir_all(&dir) { - crate::logging::warn(&format!( - "Failed to create OpenAI generated image directory: {}", - err - )); - return Some(StreamEvent::TextDelta(format!( - "\n[Generated image received ({} bytes), but Jcode could not save it.]\n", - image_bytes.len() - ))); - } - - let filename = format!("{}-{}.{}", timestamp_ms, safe_id, extension); - let path = dir.join(filename); - if let Err(err) = std::fs::write(&path, image_bytes) { - crate::logging::warn(&format!("Failed to save OpenAI generated image: {}", err)); - return Some(StreamEvent::TextDelta( - "\n[Generated image received, but Jcode could not save it.]\n".to_string(), - )); - } - - let metadata_path = path.with_extension("json"); - let mut response_item = item.clone(); - if let Some(object) = response_item.as_object_mut() { - object.remove("result"); - } - let revised_prompt = item - .get("revised_prompt") - .and_then(|v| v.as_str()) - .map(str::to_string); - let metadata = serde_json::json!({ - "schema_version": 1, - "provider": "openai", - "native_tool": "image_generation", - "id": item_id, - "status": item.get("status").and_then(|v| v.as_str()), - "created_at_unix_ms": timestamp_ms, - "image_path": path.display().to_string(), - "output_format": output_format, - "byte_count": std::fs::metadata(&path).map(|m| m.len()).unwrap_or_default(), - "revised_prompt": revised_prompt, - "response_item": response_item, - }); - let metadata_path_string = match serde_json::to_vec_pretty(&metadata).ok().and_then(|bytes| { - std::fs::write(&metadata_path, bytes) - .ok() - .map(|_| metadata_path.clone()) - }) { - Some(path) => Some(path.display().to_string()), - None => { - crate::logging::warn("Failed to save OpenAI generated image metadata"); - None - } - }; - - let mut markdown = format!( - "\n![Generated image]({})\n\nGenerated image saved to `{}`.", - path.display(), - path.display() - ); - if let Some(metadata_path) = metadata_path_string.as_deref() { - markdown.push_str(&format!("\nMetadata saved to `{}`.", metadata_path)); - } - markdown.push('\n'); - - pending.push_back(StreamEvent::TextDelta(markdown)); - - Some(StreamEvent::GeneratedImage { - id: item_id.to_string(), - path: path.display().to_string(), - metadata_path: metadata_path_string, - output_format: output_format.to_string(), - revised_prompt, - }) -} - -pub(super) struct OpenAIResponsesStream { - inner: Pin> + Send>>, - buffer: String, - pending: VecDeque, - saw_text_delta: bool, - streaming_tool_calls: HashMap, - completed_tool_items: HashSet, -} - -impl OpenAIResponsesStream { - pub(super) fn new( - stream: impl Stream> + Send + 'static, - ) -> Self { - Self { - inner: Box::pin(stream), - buffer: String::new(), - pending: VecDeque::new(), - saw_text_delta: false, - streaming_tool_calls: HashMap::new(), - completed_tool_items: HashSet::new(), - } - } - - fn parse_next_event(&mut self) -> Option { - if let Some(event) = self.pending.pop_front() { - return Some(event); - } - - while let Some(pos) = self.buffer.find("\n\n") { - let event_str = self.buffer[..pos].to_string(); - self.buffer = self.buffer[pos + 2..].to_string(); - - let mut data_lines = Vec::new(); - for line in event_str.lines() { - if let Some(data) = crate::util::sse_data_line(line) { - data_lines.push(data); - } - } - - if data_lines.is_empty() { - continue; - } - - let data = data_lines.join("\n"); - if let Some(event) = parse_openai_response_event( - &data, - &mut self.saw_text_delta, - &mut self.streaming_tool_calls, - &mut self.completed_tool_items, - &mut self.pending, - ) { - return Some(event); - } - } - - None - } -} - -fn extract_cached_input_tokens(usage: &Value) -> Option { - usage - .get("input_tokens_details") - .or_else(|| usage.get("prompt_tokens_details")) - .and_then(|details| details.get("cached_tokens")) - .and_then(|v| v.as_u64()) -} - -fn extract_usage_from_response(response: &Value) -> Option { - let usage = response.get("usage")?; - let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64()); - let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64()); - let cache_read_input_tokens = extract_cached_input_tokens(usage); - if input_tokens.is_some() || output_tokens.is_some() || cache_read_input_tokens.is_some() { - Some(StreamEvent::TokenUsage { - input_tokens, - output_tokens, - cache_read_input_tokens, - cache_creation_input_tokens: None, - }) - } else { - None - } -} - -impl Stream for OpenAIResponsesStream { - type Item = Result; - - fn poll_next(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { - loop { - if let Some(event) = self.parse_next_event() { - return Poll::Ready(Some(Ok(event))); - } - - match self.inner.as_mut().poll_next(cx) { - Poll::Ready(Some(Ok(bytes))) => { - if let Ok(text) = std::str::from_utf8(&bytes) { - self.buffer.push_str(text); - } - } - Poll::Ready(Some(Err(e))) => { - return Poll::Ready(Some(Err(anyhow::anyhow!("Stream error: {}", e)))); - } - Poll::Ready(None) => { - return Poll::Ready(None); - } - Poll::Pending => { - return Poll::Pending; - } - } - } - } -} #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_text_wrapped_tool_call_rejects_non_object_json() { - let text = "prefix to=functions.read [1,2,3]"; - let parsed = parse_text_wrapped_tool_call(text); - assert!(parsed.is_none()); - } - - #[test] - fn parse_openai_response_event_ignores_malformed_json_chunks() { - let mut saw_text_delta = false; - let mut streaming_tool_calls = HashMap::new(); - let mut completed_tool_items = HashSet::new(); - let mut pending = VecDeque::new(); - - let event = parse_openai_response_event( - "{not-json}", - &mut saw_text_delta, - &mut streaming_tool_calls, - &mut completed_tool_items, - &mut pending, - ); - - assert!(event.is_none()); - assert!(!saw_text_delta); - assert!(streaming_tool_calls.is_empty()); - assert!(completed_tool_items.is_empty()); - assert!(pending.is_empty()); - } - - #[test] - fn response_completed_emits_message_end_even_when_payload_mentions_fallback() { - // Regression: when the model edits source that mentions the websocket - // fallback phrase, that text rides along inside structured events. A - // `response.completed` frame containing the phrase must still produce a - // MessageEnd, otherwise the stream "ends before the completion marker". - let mut saw_text_delta = false; - let mut streaming_tool_calls = HashMap::new(); - let mut completed_tool_items = HashSet::new(); - let mut pending = VecDeque::new(); - - let payload = serde_json::json!({ - "type": "response.completed", - "response": { - "status": "completed", - "output": [{ - "type": "message", - "role": "assistant", - "content": [{ - "type": "output_text", - "text": "falling back from websockets to https transport" - }] - }] - } - }) - .to_string(); - - let event = parse_openai_response_event( - &payload, - &mut saw_text_delta, - &mut streaming_tool_calls, - &mut completed_tool_items, - &mut pending, - ); - - assert!( - matches!(event, Some(StreamEvent::MessageEnd { .. })), - "expected MessageEnd, got {event:?}" - ); - } - - #[test] - fn function_call_arguments_with_fallback_phrase_still_emit_tool_call() { - let mut saw_text_delta = false; - let mut streaming_tool_calls = HashMap::new(); - let mut completed_tool_items = HashSet::new(); - let mut pending = VecDeque::new(); - - let payload = serde_json::json!({ - "type": "response.function_call_arguments.done", - "item_id": "fc_1", - "call_id": "call_1", - "name": "bash", - "arguments": "{\"command\":\"echo falling back from websockets to https transport\"}" - }) - .to_string(); - - let event = parse_openai_response_event( - &payload, - &mut saw_text_delta, - &mut streaming_tool_calls, - &mut completed_tool_items, - &mut pending, - ); - - assert!( - matches!(event, Some(StreamEvent::ToolUseStart { .. })), - "expected ToolUseStart, got {event:?}" - ); - } - - #[test] - fn plain_text_fallback_notice_is_still_dropped() { - let mut saw_text_delta = false; - let mut streaming_tool_calls = HashMap::new(); - let mut completed_tool_items = HashSet::new(); - let mut pending = VecDeque::new(); - - let event = parse_openai_response_event( - "falling back from websockets to https transport", - &mut saw_text_delta, - &mut streaming_tool_calls, - &mut completed_tool_items, - &mut pending, - ); - - assert!(event.is_none()); - } -} +pub(super) use jcode_provider_openai::stream::{ + handle_openai_output_item, parse_text_wrapped_tool_call, +}; diff --git a/crates/jcode-base/src/provider/openai_stream_runtime.rs b/crates/jcode-base/src/provider/openai_stream_runtime.rs index 5de52c39a..b5817813b 100644 --- a/crates/jcode-base/src/provider/openai_stream_runtime.rs +++ b/crates/jcode-base/src/provider/openai_stream_runtime.rs @@ -1443,75 +1443,6 @@ fn classify_unavailable_model_error(status: StatusCode, body: &str) -> Option, - top_level_error: &Option, -) -> (String, Option) { - // For "response.failed" events, the error is nested: response.error.message - // For "error"/"response.error" events, the error is top-level: error.message - let error = response - .as_ref() - .and_then(|r| r.get("error")) - .or(top_level_error.as_ref()); - - let error = match error { - Some(e) => e, - None => { - // Last resort: check if response itself has a status_message or message - if let Some(resp) = response.as_ref() - && let Some(msg) = resp - .get("status_message") - .or_else(|| resp.get("message")) - .and_then(|v| v.as_str()) - { - return (msg.to_string(), None); - } - return ( - "OpenAI response stream error (no error details)".to_string(), - None, - ); - } - }; - - let message = error - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("OpenAI response stream error (unknown)") - .to_string(); - let error_type = error.get("type").and_then(|v| v.as_str()); - let code = error.get("code").and_then(|v| v.as_str()); - - let message_lower = message.to_lowercase(); - let message = match (error_type, code) { - (Some(error_type), Some(code)) - if !message_lower.contains(&error_type.to_lowercase()) - && !message_lower.contains(&code.to_lowercase()) => - { - format!("{} ({}): {}", error_type, code, message) - } - (Some(error_type), _) if !message_lower.contains(&error_type.to_lowercase()) => { - format!("{}: {}", error_type, message) - } - (_, Some(code)) if !message_lower.contains(&code.to_lowercase()) => { - format!("{}: {}", code, message) - } - _ => message, - }; - - // Try to extract retry_after from error object or response metadata - let retry_after = error - .get("retry_after") - .and_then(|v| v.as_u64()) - .or_else(|| { - response - .as_ref() - .and_then(|r| r.get("retry_after")) - .and_then(|v| v.as_u64()) - }); - - (message, retry_after) -} - /// Check if an error is transient and should be retried pub(super) fn is_retryable_error(error_str: &str) -> bool { // Network/connection errors diff --git a/crates/jcode-provider-openai/Cargo.toml b/crates/jcode-provider-openai/Cargo.toml index eea97ad15..6054e881a 100644 --- a/crates/jcode-provider-openai/Cargo.toml +++ b/crates/jcode-provider-openai/Cargo.toml @@ -5,6 +5,14 @@ edition = "2024" publish = false [dependencies] +anyhow = "1" +base64 = "0.22" +bytes = "1" +futures = "0.3" +jcode-core = { path = "../jcode-core" } +jcode-logging = { path = "../jcode-logging" } jcode-message-types = { path = "../jcode-message-types" } jcode-provider-core = { path = "../jcode-provider-core" } +reqwest = { version = "0.12", default-features = false, features = ["stream"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/jcode-provider-openai/src/lib.rs b/crates/jcode-provider-openai/src/lib.rs index a7f5b51e9..2c2028917 100644 --- a/crates/jcode-provider-openai/src/lib.rs +++ b/crates/jcode-provider-openai/src/lib.rs @@ -1,4 +1,5 @@ pub mod request; +pub mod stream; pub use request::{ OPENAI_ENCRYPTED_CONTENT_PROVIDER_MAX_CHARS, OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS, diff --git a/crates/jcode-provider-openai/src/stream.rs b/crates/jcode-provider-openai/src/stream.rs new file mode 100644 index 000000000..85328b9e1 --- /dev/null +++ b/crates/jcode-provider-openai/src/stream.rs @@ -0,0 +1,993 @@ +use anyhow::Result; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use bytes::Bytes; +use futures::Stream; +use jcode_message_types::{StreamEvent, sanitize_tool_id}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::task::{Context as TaskContext, Poll}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const WEBSOCKET_FALLBACK_NOTICE: &str = "falling back from websockets to https transport"; +static FALLBACK_TOOL_CALL_COUNTER: AtomicU64 = AtomicU64::new(1); +static RECOVERED_TEXT_WRAPPED_TOOL_CALLS: AtomicU64 = AtomicU64::new(0); +static NORMALIZED_NULL_TOOL_ARGUMENTS: AtomicU64 = AtomicU64::new(0); + +fn truncated_stream_payload_context(data: &str) -> String { + jcode_core::util::truncate_str(&data.trim().replace("\n", "\\n"), 240).to_string() +} + +fn is_structured_response_event(data: &str) -> bool { + let Ok(value) = serde_json::from_str::(data) else { + return false; + }; + let Some(kind) = value.get("type").and_then(|kind| kind.as_str()) else { + return false; + }; + kind.starts_with("response.") || kind == "error" +} + +fn is_websocket_fallback_notice(data: &str) -> bool { + // The proxy injects the fallback notice as a plain-text control frame, not a + // structured Responses API event. A legitimate `response.*`/`error` event can + // contain this phrase in model output or tool-call arguments and must still be + // parsed normally. + if is_structured_response_event(data) { + return false; + } + data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE) +} + +fn extract_error_with_retry( + response: &Option, + top_level_error: &Option, +) -> (String, Option) { + let error = response + .as_ref() + .and_then(|r| r.get("error")) + .or(top_level_error.as_ref()); + + let error = match error { + Some(e) => e, + None => { + if let Some(resp) = response.as_ref() + && let Some(msg) = resp + .get("status_message") + .or_else(|| resp.get("message")) + .and_then(|v| v.as_str()) + { + return (msg.to_string(), None); + } + return ( + "OpenAI response stream error (no error details)".to_string(), + None, + ); + } + }; + + let message = error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("OpenAI response stream error (unknown)") + .to_string(); + let error_type = error.get("type").and_then(|v| v.as_str()); + let code = error.get("code").and_then(|v| v.as_str()); + + let message_lower = message.to_lowercase(); + let message = match (error_type, code) { + (Some(error_type), Some(code)) + if !message_lower.contains(&error_type.to_lowercase()) + && !message_lower.contains(&code.to_lowercase()) => + { + format!("{} ({}): {}", error_type, code, message) + } + (Some(error_type), _) if !message_lower.contains(&error_type.to_lowercase()) => { + format!("{}: {}", error_type, message) + } + (_, Some(code)) if !message_lower.contains(&code.to_lowercase()) => { + format!("{}: {}", code, message) + } + _ => message, + }; + + let retry_after = error + .get("retry_after") + .and_then(|v| v.as_u64()) + .or_else(|| { + response + .as_ref() + .and_then(|r| r.get("retry_after")) + .and_then(|v| v.as_u64()) + }); + + (message, retry_after) +} +pub fn parse_text_wrapped_tool_call(text: &str) -> Option<(String, String, String, String)> { + let marker = "to=functions."; + let marker_idx = text.find(marker)?; + let after_marker = &text[marker_idx + marker.len()..]; + + let mut tool_name_end = 0usize; + for (idx, ch) in after_marker.char_indices() { + if ch.is_ascii_alphanumeric() || ch == '_' { + tool_name_end = idx + ch.len_utf8(); + } else { + break; + } + } + if tool_name_end == 0 { + return None; + } + + let tool_name = after_marker[..tool_name_end].to_string(); + let remaining = &after_marker[tool_name_end..]; + let mut fallback: Option<(String, String, String, String)> = None; + for (brace_idx, ch) in remaining.char_indices() { + if ch != '{' { + continue; + } + let slice = &remaining[brace_idx..]; + let mut stream = serde_json::Deserializer::from_str(slice).into_iter::(); + let parsed = match stream.next() { + Some(Ok(value)) => value, + Some(Err(_)) => continue, + None => continue, + }; + let consumed = stream.byte_offset(); + if !parsed.is_object() { + continue; + } + + let prefix = text[..marker_idx].trim_end().to_string(); + let suffix = remaining[brace_idx + consumed..].trim().to_string(); + let args = serde_json::to_string(&parsed).ok()?; + if suffix.is_empty() { + return Some((prefix, tool_name.clone(), args, suffix)); + } + if fallback.is_none() { + fallback = Some((prefix, tool_name.clone(), args, suffix)); + } + } + + fallback +} + +fn stream_text_or_recovered_tool_call( + text: &str, + pending: &mut VecDeque, +) -> Option { + if text.is_empty() { + return None; + } + + if let Some((prefix, tool_name, arguments, suffix)) = parse_text_wrapped_tool_call(text) { + let total = RECOVERED_TEXT_WRAPPED_TOOL_CALLS.fetch_add(1, Ordering::Relaxed) + 1; + jcode_logging::warn(&format!( + "[openai] Recovered text-wrapped tool call for '{}' (total={})", + tool_name, total + )); + let suffix = sanitize_recovered_tool_suffix(&suffix); + if !prefix.is_empty() { + pending.push_back(StreamEvent::TextDelta(prefix)); + } + pending.push_back(StreamEvent::ToolUseStart { + id: format!( + "fallback_text_call_{}", + FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) + ), + name: tool_name, + }); + pending.push_back(StreamEvent::ToolInputDelta(arguments)); + pending.push_back(StreamEvent::ToolUseEnd); + if !suffix.is_empty() { + pending.push_back(StreamEvent::TextDelta(suffix)); + } + return pending.pop_front(); + } + + Some(StreamEvent::TextDelta(text.to_string())) +} + +fn sanitize_recovered_tool_suffix(suffix: &str) -> String { + let trimmed = suffix.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let normalized = trimmed.trim_start_matches('"'); + + if normalized.starts_with(",\"item_id\"") + || normalized.starts_with(",\"output_index\"") + || normalized.starts_with(",\"sequence_number\"") + || normalized.starts_with(",\"call_id\"") + || normalized.starts_with(",\"type\":\"response.") + || (normalized.starts_with(',') + && normalized.contains("\"item_id\"") + && (normalized.contains("\"output_index\"") + || normalized.contains("\"sequence_number\""))) + { + return String::new(); + } + + suffix.to_string() +} + +#[derive(Deserialize, Debug)] +struct ResponseSseEvent { + #[serde(rename = "type")] + kind: String, + item: Option, + delta: Option, + item_id: Option, + call_id: Option, + name: Option, + arguments: Option, + response: Option, + error: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct StreamingToolCallState { + call_id: Option, + name: Option, + arguments: String, +} + +fn normalize_openai_tool_arguments(raw_arguments: String) -> String { + let trimmed = raw_arguments.trim(); + if trimmed.is_empty() || trimmed == "null" { + let total = NORMALIZED_NULL_TOOL_ARGUMENTS.fetch_add(1, Ordering::Relaxed) + 1; + jcode_logging::warn(&format!( + "[openai] Normalized empty/null tool arguments to empty object (total={})", + total + )); + "{}".to_string() + } else { + raw_arguments + } +} + +fn streaming_tool_item_id(item: &Value) -> Option { + item.get("id") + .and_then(|v| v.as_str()) + .or_else(|| item.get("item_id").and_then(|v| v.as_str())) + .map(|id| id.to_string()) +} + +fn stream_tool_call_from_state( + item_id: Option, + mut state: StreamingToolCallState, + pending: &mut VecDeque, +) -> Option { + let tool_name = state.name.take().filter(|name| !name.is_empty())?; + let raw_call_id = state + .call_id + .take() + .filter(|id| !id.is_empty()) + .or(item_id) + .unwrap_or_else(|| { + format!( + "fallback_text_call_{}", + FALLBACK_TOOL_CALL_COUNTER.fetch_add(1, Ordering::Relaxed) + ) + }); + let call_id = sanitize_tool_id(&raw_call_id); + let arguments = normalize_openai_tool_arguments(if state.arguments.is_empty() { + "{}".to_string() + } else { + state.arguments + }); + + pending.push_back(StreamEvent::ToolUseStart { + id: call_id, + name: tool_name, + }); + pending.push_back(StreamEvent::ToolInputDelta(arguments)); + pending.push_back(StreamEvent::ToolUseEnd); + pending.pop_front() +} + +pub fn parse_openai_response_event( + data: &str, + saw_text_delta: &mut bool, + streaming_tool_calls: &mut HashMap, + completed_tool_items: &mut HashSet, + pending: &mut VecDeque, +) -> Option { + if data == "[DONE]" { + return Some(StreamEvent::MessageEnd { stop_reason: None }); + } + + if is_websocket_fallback_notice(data) { + jcode_logging::warn(&format!("OpenAI stream transport notice: {}", data.trim())); + return None; + } + + if data + .to_lowercase() + .contains("stream disconnected before completion") + && !is_structured_response_event(data) + { + return Some(StreamEvent::Error { + message: data.to_string(), + retry_after_secs: None, + }); + } + + let event: ResponseSseEvent = match serde_json::from_str(data) { + Ok(parsed) => parsed, + Err(error) => { + jcode_logging::warn(&format!( + "OpenAI SSE JSON parse failed: {} payload={}", + error, + truncated_stream_payload_context(data) + )); + return None; + } + }; + + match event.kind.as_str() { + "response.output_text.delta" => { + if let Some(delta) = event.delta { + *saw_text_delta = true; + return stream_text_or_recovered_tool_call(&delta, pending); + } + } + "response.reasoning.delta" | "response.reasoning_summary_text.delta" => { + if let Some(delta) = event.delta { + return Some(StreamEvent::ThinkingDelta(delta)); + } + } + "response.reasoning.done" | "response.output_item.added" => { + if let Some(item) = &event.item { + if item.get("type").and_then(|v| v.as_str()) == Some("reasoning") { + return Some(StreamEvent::ThinkingStart); + } + if matches!( + item.get("type").and_then(|v| v.as_str()), + Some("function_call") | Some("custom_tool_call") + ) && let Some(item_id) = streaming_tool_item_id(item) + { + let state = streaming_tool_calls.entry(item_id).or_default(); + state.call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| state.call_id.clone()); + state.name = item + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| state.name.clone()); + if let Some(arguments) = item + .get("arguments") + .and_then(|v| v.as_str()) + .or_else(|| item.get("input").and_then(|v| v.as_str())) + { + state.arguments = arguments.to_string(); + } else if let Some(input) = item.get("input") + && (input.is_object() || input.is_array()) + { + state.arguments = input.to_string(); + } + } + } + } + "response.function_call_arguments.delta" => { + if let Some(item_id) = event.item_id { + let state = streaming_tool_calls.entry(item_id).or_default(); + if let Some(call_id) = event.call_id { + state.call_id = Some(call_id); + } + if let Some(name) = event.name { + state.name = Some(name); + } + if let Some(delta) = event.delta { + state.arguments.push_str(&delta); + } + } + } + "response.function_call_arguments.done" => { + if let Some(item_id) = event.item_id { + let mut state = streaming_tool_calls.remove(&item_id).unwrap_or_default(); + if let Some(call_id) = event.call_id { + state.call_id = Some(call_id); + } + if let Some(name) = event.name { + state.name = Some(name); + } + if let Some(arguments) = event.arguments { + state.arguments = arguments; + } + if let Some(tool_event) = + stream_tool_call_from_state(Some(item_id.clone()), state.clone(), pending) + { + completed_tool_items.insert(item_id); + return Some(tool_event); + } + streaming_tool_calls.insert(item_id, state); + } + } + "response.output_item.done" => { + if let Some(item) = event.item { + if let Some(item_id) = streaming_tool_item_id(&item) + && completed_tool_items.contains(&item_id) + && matches!( + item.get("type").and_then(|v| v.as_str()), + Some("function_call") | Some("custom_tool_call") + ) + { + completed_tool_items.remove(&item_id); + return None; + } + if let Some(event) = handle_openai_output_item(item, saw_text_delta, pending) { + return Some(event); + } + } + } + "response.incomplete" => { + let stop_reason = event + .response + .as_ref() + .and_then(extract_stop_reason_from_response) + .or_else(|| Some("incomplete".to_string())); + if let Some(response) = event.response + && let Some(usage_event) = extract_usage_from_response(&response) + { + pending.push_back(usage_event); + } + pending.push_back(StreamEvent::MessageEnd { stop_reason }); + return pending.pop_front(); + } + "response.completed" => { + let stop_reason = event + .response + .as_ref() + .and_then(extract_stop_reason_from_response); + if let Some(response) = event.response + && let Some(usage_event) = extract_usage_from_response(&response) + { + pending.push_back(usage_event); + } + pending.push_back(StreamEvent::MessageEnd { stop_reason }); + return pending.pop_front(); + } + "response.failed" | "response.error" | "error" => { + jcode_logging::warn(&format!( + "OpenAI stream error event (type={}): response={:?}, error={:?}", + event.kind, event.response, event.error + )); + let (message, retry_after_secs) = + extract_error_with_retry(&event.response, &event.error); + return Some(StreamEvent::Error { + message, + retry_after_secs, + }); + } + _ => {} + } + + None +} + +fn extract_last_assistant_message_phase(response: &Value) -> Option { + let output = response.get("output")?.as_array()?; + output.iter().rev().find_map(|item| { + if item.get("type").and_then(|v| v.as_str()) != Some("message") { + return None; + } + if item.get("role").and_then(|v| v.as_str()) != Some("assistant") { + return None; + } + item.get("phase") + .and_then(|v| v.as_str()) + .map(|phase| phase.to_string()) + }) +} + +fn extract_stop_reason_from_response(response: &Value) -> Option { + let status = response.get("status").and_then(|v| v.as_str()); + if status == Some("completed") { + if extract_last_assistant_message_phase(response).as_deref() == Some("commentary") { + return Some("commentary".to_string()); + } + return None; + } + + let incomplete_reason = response + .get("incomplete_details") + .and_then(|v| v.get("reason")) + .and_then(|v| v.as_str()); + + if let Some(reason) = incomplete_reason { + return Some(reason.to_string()); + } + + status + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) +} + +pub fn handle_openai_output_item( + item: Value, + saw_text_delta: &mut bool, + pending: &mut VecDeque, +) -> Option { + let item_type = item.get("type")?.as_str()?; + match item_type { + "compaction" => { + let encrypted_content = item + .get("encrypted_content") + .and_then(|v| v.as_str()) + .map(|value| value.to_string())?; + return Some(StreamEvent::Compaction { + trigger: "openai_native_auto".to_string(), + pre_tokens: None, + openai_encrypted_content: Some(encrypted_content), + }); + } + "function_call" | "custom_tool_call" => { + let call_id = item + .get("call_id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let raw_arguments = item + .get("arguments") + .and_then(|v| v.as_str().map(|s| s.to_string())) + .or_else(|| { + item.get("input").and_then(|v| { + if v.is_object() || v.is_array() { + Some(v.to_string()) + } else { + v.as_str().map(|s| s.to_string()) + } + }) + }) + .unwrap_or_else(|| "{}".to_string()); + let arguments = normalize_openai_tool_arguments(raw_arguments); + + pending.push_back(StreamEvent::ToolUseStart { + id: call_id.clone(), + name, + }); + pending.push_back(StreamEvent::ToolInputDelta(arguments)); + pending.push_back(StreamEvent::ToolUseEnd); + return pending.pop_front(); + } + "image_generation_call" => { + if let Some(event) = handle_openai_image_generation_item(&item, pending) { + return Some(event); + } + } + "message" => { + if *saw_text_delta { + return None; + } + let mut text = String::new(); + if let Some(content) = item.get("content").and_then(|v| v.as_array()) { + for entry in content { + let entry_type = entry.get("type").and_then(|v| v.as_str()); + if matches!(entry_type, Some("output_text") | Some("text")) + && let Some(t) = entry.get("text").and_then(|v| v.as_str()) + { + text.push_str(t); + } + } + } + return stream_text_or_recovered_tool_call(&text, pending); + } + "reasoning" => { + let id = item + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let mut summary = Vec::new(); + if let Some(summary_arr) = item.get("summary").and_then(|v| v.as_array()) { + for summary_item in summary_arr { + if summary_item.get("type").and_then(|v| v.as_str()) == Some("summary_text") + && let Some(text) = summary_item.get("text").and_then(|v| v.as_str()) + { + summary.push(text.to_string()); + } + } + } + let encrypted_content = item + .get("encrypted_content") + .and_then(|v| v.as_str()) + .map(|value| value.to_string()); + let status = item + .get("status") + .and_then(|v| v.as_str()) + .map(|value| value.to_string()); + + if !id.is_empty() && (encrypted_content.is_some() || !summary.is_empty()) { + pending.push_back(StreamEvent::OpenAIReasoning { + id, + summary: summary.clone(), + encrypted_content, + status, + }); + } + + if !summary.is_empty() { + pending.push_back(StreamEvent::ThinkingStart); + pending.push_back(StreamEvent::ThinkingDelta(summary.join("\n"))); + pending.push_back(StreamEvent::ThinkingEnd); + return pending.pop_front(); + } + return pending.pop_front(); + } + _ => {} + } + + None +} + +fn handle_openai_image_generation_item( + item: &Value, + pending: &mut VecDeque, +) -> Option { + let result_b64 = item.get("result")?.as_str()?; + if result_b64.is_empty() { + return None; + } + + let image_bytes = match BASE64_STANDARD.decode(result_b64) { + Ok(bytes) => bytes, + Err(err) => { + jcode_logging::warn(&format!( + "OpenAI image_generation_call returned invalid base64: {}", + err + )); + return Some(StreamEvent::TextDelta( + "\n[Generated image received, but Jcode could not decode it.]\n".to_string(), + )); + } + }; + + let output_format = item + .get("output_format") + .and_then(|v| v.as_str()) + .unwrap_or("png"); + let extension = match output_format { + "jpeg" | "jpg" => "jpg", + "webp" => "webp", + _ => "png", + }; + let item_id = item.get("id").and_then(|v| v.as_str()).unwrap_or("image"); + let safe_id: String = item_id + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '-') + .take(80) + .collect(); + let safe_id = if safe_id.is_empty() { + "image".to_string() + } else { + safe_id + }; + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default(); + let dir = std::env::current_dir() + .unwrap_or_else(|_| std::env::temp_dir()) + .join(".jcode") + .join("generated-images"); + if let Err(err) = std::fs::create_dir_all(&dir) { + jcode_logging::warn(&format!( + "Failed to create OpenAI generated image directory: {}", + err + )); + return Some(StreamEvent::TextDelta(format!( + "\n[Generated image received ({} bytes), but Jcode could not save it.]\n", + image_bytes.len() + ))); + } + + let filename = format!("{}-{}.{}", timestamp_ms, safe_id, extension); + let path = dir.join(filename); + if let Err(err) = std::fs::write(&path, image_bytes) { + jcode_logging::warn(&format!("Failed to save OpenAI generated image: {}", err)); + return Some(StreamEvent::TextDelta( + "\n[Generated image received, but Jcode could not save it.]\n".to_string(), + )); + } + + let metadata_path = path.with_extension("json"); + let mut response_item = item.clone(); + if let Some(object) = response_item.as_object_mut() { + object.remove("result"); + } + let revised_prompt = item + .get("revised_prompt") + .and_then(|v| v.as_str()) + .map(str::to_string); + let metadata = serde_json::json!({ + "schema_version": 1, + "provider": "openai", + "native_tool": "image_generation", + "id": item_id, + "status": item.get("status").and_then(|v| v.as_str()), + "created_at_unix_ms": timestamp_ms, + "image_path": path.display().to_string(), + "output_format": output_format, + "byte_count": std::fs::metadata(&path).map(|m| m.len()).unwrap_or_default(), + "revised_prompt": revised_prompt, + "response_item": response_item, + }); + let metadata_path_string = match serde_json::to_vec_pretty(&metadata).ok().and_then(|bytes| { + std::fs::write(&metadata_path, bytes) + .ok() + .map(|_| metadata_path.clone()) + }) { + Some(path) => Some(path.display().to_string()), + None => { + jcode_logging::warn("Failed to save OpenAI generated image metadata"); + None + } + }; + + let mut markdown = format!( + "\n![Generated image]({})\n\nGenerated image saved to `{}`.", + path.display(), + path.display() + ); + if let Some(metadata_path) = metadata_path_string.as_deref() { + markdown.push_str(&format!("\nMetadata saved to `{}`.", metadata_path)); + } + markdown.push('\n'); + + pending.push_back(StreamEvent::TextDelta(markdown)); + + Some(StreamEvent::GeneratedImage { + id: item_id.to_string(), + path: path.display().to_string(), + metadata_path: metadata_path_string, + output_format: output_format.to_string(), + revised_prompt, + }) +} + +pub struct OpenAIResponsesStream { + inner: Pin> + Send>>, + buffer: String, + pending: VecDeque, + saw_text_delta: bool, + streaming_tool_calls: HashMap, + completed_tool_items: HashSet, +} + +impl OpenAIResponsesStream { + pub fn new(stream: impl Stream> + Send + 'static) -> Self { + Self { + inner: Box::pin(stream), + buffer: String::new(), + pending: VecDeque::new(), + saw_text_delta: false, + streaming_tool_calls: HashMap::new(), + completed_tool_items: HashSet::new(), + } + } + + fn parse_next_event(&mut self) -> Option { + if let Some(event) = self.pending.pop_front() { + return Some(event); + } + + while let Some(pos) = self.buffer.find("\n\n") { + let event_str = self.buffer[..pos].to_string(); + self.buffer = self.buffer[pos + 2..].to_string(); + + let mut data_lines = Vec::new(); + for line in event_str.lines() { + if let Some(data) = jcode_core::util::sse_data_line(line) { + data_lines.push(data); + } + } + + if data_lines.is_empty() { + continue; + } + + let data = data_lines.join("\n"); + if let Some(event) = parse_openai_response_event( + &data, + &mut self.saw_text_delta, + &mut self.streaming_tool_calls, + &mut self.completed_tool_items, + &mut self.pending, + ) { + return Some(event); + } + } + + None + } +} + +fn extract_cached_input_tokens(usage: &Value) -> Option { + usage + .get("input_tokens_details") + .or_else(|| usage.get("prompt_tokens_details")) + .and_then(|details| details.get("cached_tokens")) + .and_then(|v| v.as_u64()) +} + +fn extract_usage_from_response(response: &Value) -> Option { + let usage = response.get("usage")?; + let input_tokens = usage.get("input_tokens").and_then(|v| v.as_u64()); + let output_tokens = usage.get("output_tokens").and_then(|v| v.as_u64()); + let cache_read_input_tokens = extract_cached_input_tokens(usage); + if input_tokens.is_some() || output_tokens.is_some() || cache_read_input_tokens.is_some() { + Some(StreamEvent::TokenUsage { + input_tokens, + output_tokens, + cache_read_input_tokens, + cache_creation_input_tokens: None, + }) + } else { + None + } +} + +impl Stream for OpenAIResponsesStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + loop { + if let Some(event) = self.parse_next_event() { + return Poll::Ready(Some(Ok(event))); + } + + match self.inner.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(bytes))) => { + if let Ok(text) = std::str::from_utf8(&bytes) { + self.buffer.push_str(text); + } + } + Poll::Ready(Some(Err(e))) => { + return Poll::Ready(Some(Err(anyhow::anyhow!("Stream error: {}", e)))); + } + Poll::Ready(None) => { + return Poll::Ready(None); + } + Poll::Pending => { + return Poll::Pending; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_text_wrapped_tool_call_rejects_non_object_json() { + let text = "prefix to=functions.read [1,2,3]"; + let parsed = parse_text_wrapped_tool_call(text); + assert!(parsed.is_none()); + } + + #[test] + fn parse_openai_response_event_ignores_malformed_json_chunks() { + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let event = parse_openai_response_event( + "{not-json}", + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!(event.is_none()); + assert!(!saw_text_delta); + assert!(streaming_tool_calls.is_empty()); + assert!(completed_tool_items.is_empty()); + assert!(pending.is_empty()); + } + + #[test] + fn response_completed_emits_message_end_even_when_payload_mentions_fallback() { + // Regression: when the model edits source that mentions the websocket + // fallback phrase, that text rides along inside structured events. A + // `response.completed` frame containing the phrase must still produce a + // MessageEnd, otherwise the stream "ends before the completion marker". + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let payload = serde_json::json!({ + "type": "response.completed", + "response": { + "status": "completed", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "falling back from websockets to https transport" + }] + }] + } + }) + .to_string(); + + let event = parse_openai_response_event( + &payload, + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!( + matches!(event, Some(StreamEvent::MessageEnd { .. })), + "expected MessageEnd, got {event:?}" + ); + } + + #[test] + fn function_call_arguments_with_fallback_phrase_still_emit_tool_call() { + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let payload = serde_json::json!({ + "type": "response.function_call_arguments.done", + "item_id": "fc_1", + "call_id": "call_1", + "name": "bash", + "arguments": "{\"command\":\"echo falling back from websockets to https transport\"}" + }) + .to_string(); + + let event = parse_openai_response_event( + &payload, + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!( + matches!(event, Some(StreamEvent::ToolUseStart { .. })), + "expected ToolUseStart, got {event:?}" + ); + } + + #[test] + fn plain_text_fallback_notice_is_still_dropped() { + let mut saw_text_delta = false; + let mut streaming_tool_calls = HashMap::new(); + let mut completed_tool_items = HashSet::new(); + let mut pending = VecDeque::new(); + + let event = parse_openai_response_event( + "falling back from websockets to https transport", + &mut saw_text_delta, + &mut streaming_tool_calls, + &mut completed_tool_items, + &mut pending, + ); + + assert!(event.is_none()); + } +} From 4428678187699bf10216ad48f271b363857c065d Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:09:04 -0700 Subject: [PATCH 39/41] fix(reload): repair stale shared-server on update no-op, preserve self-dev pins When a long-lived daemon is already on the newest release, the /update no-op path never re-ran the install path that advances the shared-server channel, leaving the daemon pinned to an older binary forever. Repair the shared-server channel after no-op update checks (both hot_exec and the update module) and request a forced server reload so the daemon execs into a strictly-newer binary. Guard the repair with is_release_channel_marker so it only overwrites release-channel markers and never clobbers a deliberately-pinned self-dev shared-server build, even when that pin is older than stable. --- Cargo.lock | 1 + crates/jcode-app-core/src/agent/provider.rs | 4 +- .../src/agent/turn_execution.rs | 8 +- .../src/agent/turn_streaming_mpsc.rs | 24 +- crates/jcode-app-core/src/agent_tests.rs | 4 +- crates/jcode-app-core/src/ambient/prompt.rs | 4 +- crates/jcode-app-core/src/catchup.rs | 10 +- crates/jcode-app-core/src/replay.rs | 5 +- crates/jcode-app-core/src/server.rs | 5 +- .../src/server/client_comm_context.rs | 6 +- .../jcode-app-core/src/server/comm_session.rs | 7 +- .../src/server/comm_session_tests.rs | 38 ++- crates/jcode-app-core/src/server/comm_sync.rs | 5 +- .../src/server/debug_server_state.rs | 4 +- .../src/server/reload_recovery.rs | 13 +- .../src/tool/agentgrep/context.rs | 4 +- .../src/tool/agentgrep_tests.rs | 4 +- .../jcode-app-core/src/tool/selfdev/setup.rs | 46 +-- .../jcode-app-core/src/tool/selfdev/tests.rs | 8 +- .../src/tool/session_search_tests.rs | 4 +- crates/jcode-app-core/src/update.rs | 22 ++ crates/jcode-base/src/provider/openai.rs | 22 +- .../src/provider/openai/websocket_health.rs | 298 +----------------- crates/jcode-base/src/telemetry/tests.rs | 19 +- crates/jcode-build-support/src/lib.rs | 17 + crates/jcode-build-support/src/tests.rs | 24 ++ crates/jcode-provider-openai/Cargo.toml | 1 + crates/jcode-provider-openai/src/lib.rs | 1 + crates/jcode-provider-openai/src/request.rs | 4 +- .../src/websocket_health.rs | 290 +++++++++++++++++ .../src/tui/app/remote/server_events.rs | 19 +- crates/jcode-tui/src/tui/app/turn.rs | 17 + src/cli/hot_exec.rs | 71 +++++ 33 files changed, 607 insertions(+), 402 deletions(-) create mode 100644 crates/jcode-provider-openai/src/websocket_health.rs diff --git a/Cargo.lock b/Cargo.lock index 874bc0bb3..c33ab2598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3899,6 +3899,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "tokio", ] [[package]] diff --git a/crates/jcode-app-core/src/agent/provider.rs b/crates/jcode-app-core/src/agent/provider.rs index e1aeefa05..59d977c66 100644 --- a/crates/jcode-app-core/src/agent/provider.rs +++ b/crates/jcode-app-core/src/agent/provider.rs @@ -172,9 +172,7 @@ impl Agent { /// the provider distinguishes OAuth (subscription) from API key (cost). /// Resolved authoritatively here so remote clients can render billing/usage /// without re-deriving it from the provider name. - pub fn active_resolved_credential( - &self, - ) -> Option { + pub fn active_resolved_credential(&self) -> Option { self.provider.active_resolved_credential() } diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs index d61077ddd..146296dea 100644 --- a/crates/jcode-app-core/src/agent/turn_execution.rs +++ b/crates/jcode-app-core/src/agent/turn_execution.rs @@ -325,8 +325,8 @@ impl Agent { fn apply_selfdev_tool_surface(tools: &mut [ToolDefinition], is_canary: bool) { for tool in tools.iter_mut() { if tool.name == "selfdev" { - tool.description = crate::tool::selfdev::SelfDevTool::description_for(is_canary) - .to_string(); + tool.description = + crate::tool::selfdev::SelfDevTool::description_for(is_canary).to_string(); tool.input_schema = crate::tool::selfdev::SelfDevTool::schema_for(is_canary); } } @@ -401,7 +401,9 @@ impl Agent { vec![ContentBlock::ToolUse { id: tool_call_id, name: tool_name, - input, thought_signature: None, }], + input, + thought_signature: None, + }], ); self.session.save()?; Ok(message_id) diff --git a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs index fa8246a81..13f65df1a 100644 --- a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs +++ b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs @@ -438,8 +438,9 @@ impl Agent { // answer renders as a normal paragraph rather than as reasoning. if reasoning_open && !text.trim().is_empty() { reasoning_open = false; - let _ = event_tx - .send(ServerEvent::ReasoningDone { duration_secs: None }); + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); } text_content.push_str(&text); if !text_wrapped_detected { @@ -474,8 +475,9 @@ impl Agent { StreamEvent::ToolUseStart { id, name } => { if reasoning_open { reasoning_open = false; - let _ = event_tx - .send(ServerEvent::ReasoningDone { duration_secs: None }); + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); } let _ = event_tx.send(ServerEvent::ToolStart { id: id.clone(), @@ -631,8 +633,9 @@ impl Agent { // step) so the client flushes its live partial line. if reasoning_open { reasoning_open = false; - let _ = event_tx - .send(ServerEvent::ReasoningDone { duration_secs: None }); + let _ = event_tx.send(ServerEvent::ReasoningDone { + duration_secs: None, + }); } if reason.is_some() { stop_reason = reason; @@ -891,7 +894,9 @@ impl Agent { content_blocks.push(ContentBlock::ToolUse { id: tc.id.clone(), name: tc.name.clone(), - input: tc.input.clone(), thought_signature: None, }); + input: tc.input.clone(), + thought_signature: None, + }); } let assistant_message_id = if !content_blocks.is_empty() { @@ -1448,7 +1453,10 @@ mod tests { // exists once both halves are appended; the overlap window must catch it. let mut acc = String::new(); acc.push_str("answer to=fun"); - assert_eq!(find_wrap_marker_incremental(&acc, "answer to=fun".len()), None); + assert_eq!( + find_wrap_marker_incremental(&acc, "answer to=fun".len()), + None + ); acc.push_str("ctions.tool"); let hit = find_wrap_marker_incremental(&acc, "ctions.tool".len()); assert_eq!(hit, find_wrap_marker_full(&acc)); diff --git a/crates/jcode-app-core/src/agent_tests.rs b/crates/jcode-app-core/src/agent_tests.rs index 9e472bce1..9f115a19a 100644 --- a/crates/jcode-app-core/src/agent_tests.rs +++ b/crates/jcode-app-core/src/agent_tests.rs @@ -703,7 +703,9 @@ async fn build_memory_prompt_nonblocking_defers_pending_memory_during_tool_loop( content: vec![ContentBlock::ToolUse { id: "call_1".to_string(), name: "bash".to_string(), - input: serde_json::json!({}), thought_signature: None, }], + input: serde_json::json!({}), + thought_signature: None, + }], timestamp: Some(chrono::Utc::now()), tool_duration_ms: None, }, diff --git a/crates/jcode-app-core/src/ambient/prompt.rs b/crates/jcode-app-core/src/ambient/prompt.rs index 2cca08b28..ed3f5927f 100644 --- a/crates/jcode-app-core/src/ambient/prompt.rs +++ b/crates/jcode-app-core/src/ambient/prompt.rs @@ -219,7 +219,9 @@ pub fn gather_recent_sessions(since: Option>) -> Vec= load_budget { diff --git a/crates/jcode-app-core/src/catchup.rs b/crates/jcode-app-core/src/catchup.rs index 536bd312c..06a0d75f2 100644 --- a/crates/jcode-app-core/src/catchup.rs +++ b/crates/jcode-app-core/src/catchup.rs @@ -46,11 +46,7 @@ impl CatchupSeenSnapshot { if !is_attention_status(status) { return false; } - let seen = self - .state - .seen_at_ms_by_session - .get(session_id) - .copied(); + let seen = self.state.seen_at_ms_by_session.get(session_id).copied(); needs_catchup_with_seen(updated_at.timestamp_millis(), seen, status) } } @@ -632,7 +628,9 @@ mod tests { vec![ContentBlock::ToolUse { id: "tool_1".to_string(), name: "read".to_string(), - input: serde_json::json!({"file_path": "src/tui/session_picker.rs"}), thought_signature: None, }], + input: serde_json::json!({"file_path": "src/tui/session_picker.rs"}), + thought_signature: None, + }], ); session.add_message( Role::Assistant, diff --git a/crates/jcode-app-core/src/replay.rs b/crates/jcode-app-core/src/replay.rs index 152ba25c0..a429d692b 100644 --- a/crates/jcode-app-core/src/replay.rs +++ b/crates/jcode-app-core/src/replay.rs @@ -232,7 +232,10 @@ pub fn export_timeline(session: &Session) -> Vec { .content .iter() .filter_map(|b| { - if let ContentBlock::ToolUse { id, name, input, .. } = b { + if let ContentBlock::ToolUse { + id, name, input, .. + } = b + { Some((id.clone(), name.clone(), input.clone())) } else { None diff --git a/crates/jcode-app-core/src/server.rs b/crates/jcode-app-core/src/server.rs index b02832c5f..4f6963c79 100644 --- a/crates/jcode-app-core/src/server.rs +++ b/crates/jcode-app-core/src/server.rs @@ -58,9 +58,8 @@ use self::runtime::ServerRuntime; use self::swarm::{ broadcast_swarm_plan, broadcast_swarm_plan_with_previous, broadcast_swarm_status, record_swarm_event, record_swarm_event_for_session, refresh_swarm_task_staleness, - remove_plan_participant, remove_session_from_swarm, - rename_plan_participant, run_swarm_message, update_member_status, - update_member_status_with_report, + remove_plan_participant, remove_session_from_swarm, rename_plan_participant, run_swarm_message, + update_member_status, update_member_status_with_report, }; use self::swarm_channels::{ remove_session_channel_subscriptions, subscribe_session_to_channel, diff --git a/crates/jcode-app-core/src/server/client_comm_context.rs b/crates/jcode-app-core/src/server/client_comm_context.rs index 6bf43fe1c..b51dae2cd 100644 --- a/crates/jcode-app-core/src/server/client_comm_context.rs +++ b/crates/jcode-app-core/src/server/client_comm_context.rs @@ -1,8 +1,8 @@ +use super::debug::ClientConnectionInfo; use super::{ - FileTouchService, SharedContext, SwarmEvent, SwarmEventType, SwarmMember, - fanout_session_event, record_swarm_event, + FileTouchService, SharedContext, SwarmEvent, SwarmEventType, SwarmMember, fanout_session_event, + record_swarm_event, }; -use super::debug::ClientConnectionInfo; use crate::protocol::{AgentInfo, ContextEntry, NotificationType, ServerEvent}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; diff --git a/crates/jcode-app-core/src/server/comm_session.rs b/crates/jcode-app-core/src/server/comm_session.rs index 7cb032462..070f6e76a 100644 --- a/crates/jcode-app-core/src/server/comm_session.rs +++ b/crates/jcode-app-core/src/server/comm_session.rs @@ -307,9 +307,10 @@ fn resolve_swarm_spawn_selection( } None => SwarmSpawnSelection { model: coordinator.model.clone(), - provider_key: coordinator.provider_key.clone().or_else(|| { - provider_key_for_spawn_model(coordinator.model.as_deref(), None) - }), + provider_key: coordinator + .provider_key + .clone() + .or_else(|| provider_key_for_spawn_model(coordinator.model.as_deref(), None)), route_api_method: coordinator.route_api_method.clone(), }, } diff --git a/crates/jcode-app-core/src/server/comm_session_tests.rs b/crates/jcode-app-core/src/server/comm_session_tests.rs index f8df50428..f62cb846f 100644 --- a/crates/jcode-app-core/src/server/comm_session_tests.rs +++ b/crates/jcode-app-core/src/server/comm_session_tests.rs @@ -466,7 +466,11 @@ fn resolve_swarm_spawn_model_inherits_coordinator_auth_route_for_oauth_vs_api() // the same API route, not Claude OAuth (the config default). let selection = resolve_swarm_spawn_selection( None, - &coordinator_identity(Some("claude-opus-4-6"), Some("claude-api"), Some("claude-api")), + &coordinator_identity( + Some("claude-opus-4-6"), + Some("claude-api"), + Some("claude-api"), + ), ); assert_eq!(selection.model.as_deref(), Some("claude-opus-4-6")); @@ -478,7 +482,11 @@ fn resolve_swarm_spawn_model_inherits_coordinator_auth_route_for_oauth_vs_api() fn resolve_swarm_spawn_model_keeps_provider_key_when_config_matches_coordinator() { let selection = resolve_swarm_spawn_selection( Some("custom-model".to_string()), - &coordinator_identity(Some("custom-model"), Some("custom-provider"), Some("custom-route")), + &coordinator_identity( + Some("custom-model"), + Some("custom-provider"), + Some("custom-route"), + ), ); assert_eq!(selection.model.as_deref(), Some("custom-model")); @@ -501,7 +509,10 @@ fn resolve_swarm_spawn_model_openai_api_prefix_pins_api_route_over_coordinator() assert_eq!(selection.model.as_deref(), Some("gpt-5.5")); assert_eq!(selection.provider_key.as_deref(), Some("openai-api-key")); - assert_eq!(selection.route_api_method.as_deref(), Some("openai-api-key")); + assert_eq!( + selection.route_api_method.as_deref(), + Some("openai-api-key") + ); } #[test] @@ -509,12 +520,24 @@ fn resolve_swarm_spawn_model_auth_route_prefixes_pin_expected_routes() { for (configured, expected_model, expected_key) in [ ("openai-api:gpt-5.5", "gpt-5.5", "openai-api-key"), ("openai-oauth:gpt-5.5", "gpt-5.5", "openai-oauth"), - ("claude-api:claude-opus-4-8", "claude-opus-4-8", "anthropic-api-key"), - ("claude-oauth:claude-opus-4-8", "claude-opus-4-8", "claude-oauth"), + ( + "claude-api:claude-opus-4-8", + "claude-opus-4-8", + "anthropic-api-key", + ), + ( + "claude-oauth:claude-opus-4-8", + "claude-opus-4-8", + "claude-oauth", + ), ] { let selection = resolve_swarm_spawn_selection( Some(configured.to_string()), - &coordinator_identity(Some("some-other-model"), Some("some-key"), Some("some-route")), + &coordinator_identity( + Some("some-other-model"), + Some("some-key"), + Some("some-route"), + ), ); assert_eq!( selection.model.as_deref(), @@ -589,8 +612,7 @@ async fn coordinator_identity_falls_back_to_persisted_session_when_agent_busy() // Persist a coordinator session that records a concrete model + auth route. // Persist after the agent is built so it reflects the authoritative on-disk // snapshot the spawn path will read when the agent lock is unavailable. - let mut session = - crate::session::Session::create_with_id("coord_busy".to_string(), None, None); + let mut session = crate::session::Session::create_with_id("coord_busy".to_string(), None, None); session.model = Some("claude-opus-4-6".to_string()); session.provider_key = Some("claude-api".to_string()); session.route_api_method = Some("claude-api".to_string()); diff --git a/crates/jcode-app-core/src/server/comm_sync.rs b/crates/jcode-app-core/src/server/comm_sync.rs index ebbe94d88..2f5ff327a 100644 --- a/crates/jcode-app-core/src/server/comm_sync.rs +++ b/crates/jcode-app-core/src/server/comm_sync.rs @@ -139,7 +139,6 @@ pub(super) async fn member_runtime_extras( } } - async fn ensure_same_swarm_access( id: u64, req_session_id: &str, @@ -278,7 +277,9 @@ pub(super) async fn handle_comm_status( return; }; - let files_touched = file_touch.sorted_file_strings_for_session(&target_session).await; + let files_touched = file_touch + .sorted_file_strings_for_session(&target_session) + .await; let activity = { let connections = client_connections.read().await; diff --git a/crates/jcode-app-core/src/server/debug_server_state.rs b/crates/jcode-app-core/src/server/debug_server_state.rs index 1ea2e3a87..c14bf5545 100644 --- a/crates/jcode-app-core/src/server/debug_server_state.rs +++ b/crates/jcode-app-core/src/server/debug_server_state.rs @@ -1,6 +1,6 @@ use super::{ - ClientConnectionInfo, ClientDebugState, DebugJob, FileAccess, FileTouchService, - ServerIdentity, SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, + ClientConnectionInfo, ClientDebugState, DebugJob, FileAccess, FileTouchService, ServerIdentity, + SessionInterruptQueues, SharedContext, SwarmEvent, SwarmMember, VersionedPlan, }; use crate::agent::Agent; use anyhow::Result; diff --git a/crates/jcode-app-core/src/server/reload_recovery.rs b/crates/jcode-app-core/src/server/reload_recovery.rs index 20f2c4fa8..b73625d52 100644 --- a/crates/jcode-app-core/src/server/reload_recovery.rs +++ b/crates/jcode-app-core/src/server/reload_recovery.rs @@ -274,15 +274,9 @@ mod tests { // the recovery directory or collide with sibling paths. assert_eq!(sanitize_session_id("../../etc/passwd"), "______etc_passwd"); assert_eq!(sanitize_session_id("a/b\\c"), "a_b_c"); - assert_eq!( - sanitize_session_id("sess.with space"), - "sess_with_space" - ); + assert_eq!(sanitize_session_id("sess.with space"), "sess_with_space"); // Already-safe ids are preserved verbatim. - assert_eq!( - sanitize_session_id("session-abc_123"), - "session-abc_123" - ); + assert_eq!(sanitize_session_id("session-abc_123"), "session-abc_123"); } #[test] @@ -360,8 +354,7 @@ mod tests { // Reading the directive (for History payloads) must leave the durable // intent pending so a lost frame can be retried after reconnect. for _ in 0..3 { - let directive = - pending_directive_for_session(session_id)?.expect("directive present"); + let directive = pending_directive_for_session(session_id)?.expect("directive present"); assert_eq!(directive.continuation_message, "continue please"); assert!(has_pending_for_session(session_id)); } diff --git a/crates/jcode-app-core/src/tool/agentgrep/context.rs b/crates/jcode-app-core/src/tool/agentgrep/context.rs index 3811a6229..1d1c1c2aa 100644 --- a/crates/jcode-app-core/src/tool/agentgrep/context.rs +++ b/crates/jcode-app-core/src/tool/agentgrep/context.rs @@ -112,7 +112,9 @@ fn collect_tool_exposures(session: &Session) -> Vec { for (message_index, msg) in session.messages.iter().enumerate() { for block in &msg.content { match block { - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { tool_map.insert( id.clone(), ToolCall { diff --git a/crates/jcode-app-core/src/tool/agentgrep_tests.rs b/crates/jcode-app-core/src/tool/agentgrep_tests.rs index b1976c3bc..ee9aa5d12 100644 --- a/crates/jcode-app-core/src/tool/agentgrep_tests.rs +++ b/crates/jcode-app-core/src/tool/agentgrep_tests.rs @@ -653,7 +653,9 @@ fn bash_exposure_collects_file_and_line_hits() { input: json!({ "command": "cat src/tool/lsp.rs && rg -n auth_status src/tool/lsp.rs" }), - intent: None, thought_signature: None, }; + intent: None, + thought_signature: None, + }; let content = "src/tool/lsp.rs:42:let status = auth_status();\n"; collect_bash_exposure( diff --git a/crates/jcode-app-core/src/tool/selfdev/setup.rs b/crates/jcode-app-core/src/tool/selfdev/setup.rs index 3f07483fb..496329daf 100644 --- a/crates/jcode-app-core/src/tool/selfdev/setup.rs +++ b/crates/jcode-app-core/src/tool/selfdev/setup.rs @@ -21,11 +21,7 @@ impl SetupCheck { } } - fn missing( - name: &'static str, - detail: impl Into, - fix: impl Into, - ) -> Self { + fn missing(name: &'static str, detail: impl Into, fix: impl Into) -> Self { Self { name, ok: false, @@ -102,36 +98,25 @@ impl SelfDevTool { if repo_dir.is_none() { // Only attempt a clone when git is available and we're not in a // synthetic test session. - let git_available = checks - .iter() - .any(|check| check.name == "git" && check.ok); + let git_available = checks.iter().any(|check| check.name == "git" && check.ok); if SelfDevTool::is_test_session() { - clone_note = Some( - "Test mode: skipped cloning the jcode source.".to_string(), - ); + clone_note = Some("Test mode: skipped cloning the jcode source.".to_string()); } else if git_available { match Self::clone_selfdev_source() { Ok(path) => { - clone_note = Some(format!( - "Cloned jcode source into {}.", - path.display() - )); + clone_note = Some(format!("Cloned jcode source into {}.", path.display())); repo_dir = Some(path); } Err(err) => { - clone_note = Some(format!( - "Could not clone jcode source automatically: {err}", - )); + clone_note = + Some(format!("Could not clone jcode source automatically: {err}",)); } } } } match &repo_dir { - Some(path) => checks.push(SetupCheck::ok( - "repository", - path.display().to_string(), - )), + Some(path) => checks.push(SetupCheck::ok("repository", path.display().to_string())), None => { let target = Self::selfdev_clone_dir() .map(|p| p.display().to_string()) @@ -152,10 +137,9 @@ impl SelfDevTool { // build before `selfdev reload`/`enter` can hand off into a dev binary. if let Some(repo) = repo_dir.as_deref() { match build::find_dev_binary(repo) { - Some(binary) => checks.push(SetupCheck::ok( - "dev binary", - binary.display().to_string(), - )), + Some(binary) => { + checks.push(SetupCheck::ok("dev binary", binary.display().to_string())) + } None => checks.push(SetupCheck::missing( "dev binary", "no built binary in target/selfdev or target/release", @@ -222,7 +206,11 @@ impl SelfDevTool { let format_path = |path: Option<&std::path::Path>| match path { Some(p) => { let exists = p.exists(); - format!("{} {}", p.display(), if exists { "(exists)" } else { "(missing)" }) + format!( + "{} {}", + p.display(), + if exists { "(exists)" } else { "(missing)" } + ) } None => "unavailable".to_string(), }; @@ -293,9 +281,7 @@ impl SelfDevTool { /// is strictly newer than the running process). pub(super) async fn do_reload_to_newer_build(&self, _ctx: &ToolContext) -> Result { if SelfDevTool::is_test_session() { - return Ok(ToolOutput::new( - "Test mode: skipped reload-to-newer-build.", - )); + return Ok(ToolOutput::new("Test mode: skipped reload-to-newer-build.")); } if !server::server_has_newer_binary() { diff --git a/crates/jcode-app-core/src/tool/selfdev/tests.rs b/crates/jcode-app-core/src/tool/selfdev/tests.rs index 31a3b8dc3..bc6ca6509 100644 --- a/crates/jcode-app-core/src/tool/selfdev/tests.rs +++ b/crates/jcode-app-core/src/tool/selfdev/tests.rs @@ -324,7 +324,13 @@ fn non_selfdev_schema_only_exposes_onramp_actions() { sorted, vec!["enter", "find-config", "reload", "setup", "status"] ); - for hidden in ["build", "test", "cancel-build", "socket-info", "socket-help"] { + for hidden in [ + "build", + "test", + "cancel-build", + "socket-info", + "socket-help", + ] { assert!( !actions.contains(&hidden), "on-ramp schema should not expose {hidden}" diff --git a/crates/jcode-app-core/src/tool/session_search_tests.rs b/crates/jcode-app-core/src/tool/session_search_tests.rs index dad26e350..e33f321bc 100644 --- a/crates/jcode-app-core/src/tool/session_search_tests.rs +++ b/crates/jcode-app-core/src/tool/session_search_tests.rs @@ -91,7 +91,9 @@ fn tool_use_input_is_hidden_by_default_and_searchable_when_requested() { name: "websearch".to_string(), input: json!({ "query": "best time post hackernews visibility upvotes" - }), thought_signature: None, }], + }), + thought_signature: None, + }], )], ); diff --git a/crates/jcode-app-core/src/update.rs b/crates/jcode-app-core/src/update.rs index 1e4aa1bbf..1fabccef8 100644 --- a/crates/jcode-app-core/src/update.rs +++ b/crates/jcode-app-core/src/update.rs @@ -1157,6 +1157,7 @@ pub fn check_and_maybe_update(auto_install: bool) -> UpdateCheckResult { } } Ok(None) => { + repair_stale_shared_server_after_no_update(); Bus::global().publish(BusEvent::UpdateStatus(UpdateStatus::UpToDate)); let mut metadata = UpdateMetadata::load().unwrap_or_default(); metadata.last_check = SystemTime::now(); @@ -1171,6 +1172,27 @@ pub fn check_and_maybe_update(auto_install: bool) -> UpdateCheckResult { } } +fn repair_stale_shared_server_after_no_update() { + match build::repair_stale_shared_server_channel() { + Ok(build::SharedServerRepair::Repaired { + previous, + repaired_to, + }) => { + crate::logging::info(&format!( + "update: repaired stale shared-server channel {:?} -> {} after no-op update check", + previous, repaired_to + )); + } + Ok(build::SharedServerRepair::AlreadyCurrent) => {} + Err(error) => { + crate::logging::warn(&format!( + "update: failed to repair stale shared-server channel after no-op update check: {}", + error + )); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/jcode-base/src/provider/openai.rs b/crates/jcode-base/src/provider/openai.rs index 183f0a8e0..f31ba4fb1 100644 --- a/crates/jcode-base/src/provider/openai.rs +++ b/crates/jcode-base/src/provider/openai.rs @@ -36,10 +36,7 @@ const MAX_RETRIES: u32 = 3; /// Base delay for exponential backoff (in milliseconds) const RETRY_BASE_DELAY_MS: u64 = 1000; const WEBSOCKET_UPGRADE_REQUIRED_ERROR: StatusCode = StatusCode::UPGRADE_REQUIRED; -const WEBSOCKET_FALLBACK_NOTICE: &str = "falling back from websockets to https transport"; const WEBSOCKET_CONNECT_TIMEOUT_SECS: u64 = 8; -const WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS: u64 = 8; -const WEBSOCKET_COMPLETION_TIMEOUT_SECS: u64 = 300; /// Maximum age of a persistent WebSocket connection before forcing reconnect const WEBSOCKET_PERSISTENT_MAX_AGE_SECS: u64 = 3000; // 50 min (server limit is 60 min) /// Default idle window after which we reconnect instead of reusing the socket. @@ -91,11 +88,6 @@ static WEBSOCKET_PERSISTENT_IDLE_RECONNECT_SECS: LazyLock> = LazyLoc } }); const WEBSOCKET_PERSISTENT_HEALTHCHECK_TIMEOUT_MS: u64 = 1500; -/// Base websocket cooldown after a fallback in auto mode. -/// Keep this short so one flaky attempt does not pin the TUI to HTTPS for a long time. -const WEBSOCKET_MODEL_COOLDOWN_BASE_SECS: u64 = 60; -/// Maximum websocket cooldown after repeated fallback streaks. -const WEBSOCKET_MODEL_COOLDOWN_MAX_SECS: u64 = 600; const DEFAULT_MAX_OUTPUT_TOKENS: u32 = 32_768; static WEBSOCKET_COOLDOWNS: LazyLock>>> = LazyLock::new(|| Arc::new(RwLock::new(HashMap::new()))); @@ -1017,17 +1009,19 @@ mod openai_stream_runtime; mod websocket_health; +use self::websocket_health::{ + WEBSOCKET_COMPLETION_TIMEOUT_SECS, WEBSOCKET_FALLBACK_NOTICE, + WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, classify_websocket_fallback_reason, + is_stream_activity_event, is_websocket_activity_payload, is_websocket_fallback_notice, + is_websocket_first_activity_payload, record_websocket_fallback, record_websocket_success, + summarize_websocket_fallback_reason, websocket_activity_timeout_kind, + websocket_cooldown_remaining, websocket_next_activity_timeout_secs, +}; #[cfg(test)] use self::websocket_health::{ WebsocketFallbackReason, clear_websocket_cooldown, normalize_transport_model, set_websocket_cooldown, websocket_cooldown_for_streak, websocket_remaining_timeout_secs, }; -use self::websocket_health::{ - classify_websocket_fallback_reason, is_stream_activity_event, is_websocket_activity_payload, - is_websocket_fallback_notice, is_websocket_first_activity_payload, record_websocket_fallback, - record_websocket_success, summarize_websocket_fallback_reason, websocket_activity_timeout_kind, - websocket_cooldown_remaining, websocket_next_activity_timeout_secs, -}; #[cfg(test)] #[path = "openai_tests.rs"] diff --git a/crates/jcode-base/src/provider/openai/websocket_health.rs b/crates/jcode-base/src/provider/openai/websocket_health.rs index 92f7beb15..55fe1944b 100644 --- a/crates/jcode-base/src/provider/openai/websocket_health.rs +++ b/crates/jcode-base/src/provider/openai/websocket_health.rs @@ -1,293 +1,15 @@ -use super::{ +pub(super) use jcode_provider_openai::websocket_health::{ WEBSOCKET_COMPLETION_TIMEOUT_SECS, WEBSOCKET_FALLBACK_NOTICE, WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, - WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, + WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, classify_websocket_fallback_reason, + is_stream_activity_event, is_structured_response_event, is_websocket_activity_payload, + is_websocket_fallback_notice, is_websocket_first_activity_payload, record_websocket_fallback, + record_websocket_success, summarize_websocket_fallback_reason, websocket_activity_timeout_kind, + websocket_cooldown_remaining, websocket_next_activity_timeout_secs, }; -use crate::message::StreamEvent; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; -use tokio::sync::RwLock; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum WebsocketFallbackReason { - ConnectTimeout, - FirstResponseTimeout, - StreamTimeout, - ServerRequestedHttps, - ConnectFailed, - StreamClosedEarly, - WebsocketError, -} - -impl WebsocketFallbackReason { - pub(super) fn summary(self) -> &'static str { - match self { - Self::ConnectTimeout => "connect timeout", - Self::FirstResponseTimeout => "first response timeout", - Self::StreamTimeout => "stream timeout", - Self::ServerRequestedHttps => "server requested https", - Self::ConnectFailed => "connect failed", - Self::StreamClosedEarly => "stream closed early", - Self::WebsocketError => "websocket error", - } - } -} - -pub(super) fn is_websocket_fallback_notice(data: &str) -> bool { - // The proxy injects the fallback notice as a plain-text control frame, not - // a structured Responses API event. A legitimate `response.*`/`error` - // event can legitimately *contain* this phrase (for example inside - // tool-call arguments when the model is editing source that mentions - // websocket fallback), so a structured event must never be reinterpreted - // as a transport control frame. - if is_structured_response_event(data) { - return false; - } - data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE) -} - -pub(super) fn is_stream_activity_event(_event: &StreamEvent) -> bool { - true -} - -/// Returns true when `data` parses as a structured Responses API stream event -/// (a JSON object whose `type` is a `response.*` event or a top-level `error`). -/// These frames carry model output and must be parsed as protocol events even -/// if their content happens to contain transport-control phrases. -pub(super) fn is_structured_response_event(data: &str) -> bool { - is_websocket_activity_payload(data) -} - -pub(super) fn is_websocket_activity_payload(data: &str) -> bool { - let Ok(value) = serde_json::from_str::(data) else { - return false; - }; - let Some(kind) = value.get("type").and_then(|kind| kind.as_str()) else { - return false; - }; - kind.starts_with("response.") || kind == "error" -} - -pub(super) fn is_websocket_first_activity_payload(data: &str) -> bool { - let Ok(value) = serde_json::from_str::(data) else { - return false; - }; - value - .get("type") - .and_then(|kind| kind.as_str()) - .map(|kind| !kind.is_empty()) - .unwrap_or(false) -} - -pub(super) fn websocket_remaining_timeout_secs(since: Instant, timeout_secs: u64) -> Option { - let timeout = Duration::from_secs(timeout_secs); - let elapsed = since.elapsed(); - if elapsed >= timeout { - return None; - } - - Some(timeout_secs.saturating_sub(elapsed.as_secs()).max(1)) -} - -pub(super) fn websocket_next_activity_timeout_secs( - ws_started_at: Instant, - last_api_activity_at: Instant, - saw_api_activity: bool, -) -> Option { - if !saw_api_activity { - websocket_remaining_timeout_secs(ws_started_at, WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS) - } else { - websocket_remaining_timeout_secs(last_api_activity_at, WEBSOCKET_COMPLETION_TIMEOUT_SECS) - } -} - -pub(super) fn websocket_activity_timeout_kind(saw_api_activity: bool) -> &'static str { - if saw_api_activity { "next" } else { "first" } -} - -pub(super) fn classify_websocket_fallback_reason(error: &str) -> WebsocketFallbackReason { - let error = error.to_ascii_lowercase(); - if error.contains("connect timed out") { - WebsocketFallbackReason::ConnectTimeout - } else if error.contains("did not emit api activity within") - || error.contains("timed out waiting for first websocket activity") - { - WebsocketFallbackReason::FirstResponseTimeout - } else if error.contains("timed out waiting for next websocket activity") - || error.contains("did not complete within") - { - WebsocketFallbackReason::StreamTimeout - } else if error.contains("upgrade required") - || error.contains("server requested fallback") - || error.contains(WEBSOCKET_FALLBACK_NOTICE) - { - WebsocketFallbackReason::ServerRequestedHttps - } else if error.contains("failed to connect websocket stream") { - WebsocketFallbackReason::ConnectFailed - } else if error.contains("ended before response.completed") - || error.contains("closed before response.completed") - { - WebsocketFallbackReason::StreamClosedEarly - } else { - WebsocketFallbackReason::WebsocketError - } -} - -pub(super) fn summarize_websocket_fallback_reason(error: &str) -> &'static str { - classify_websocket_fallback_reason(error).summary() -} - -fn websocket_cooldown_bounds_for_reason(reason: WebsocketFallbackReason) -> (u64, u64) { - match reason { - WebsocketFallbackReason::ServerRequestedHttps => ( - WEBSOCKET_MODEL_COOLDOWN_BASE_SECS.saturating_mul(5), - WEBSOCKET_MODEL_COOLDOWN_MAX_SECS.saturating_mul(3), - ), - WebsocketFallbackReason::StreamTimeout => ( - WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, - WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, - ), - WebsocketFallbackReason::ConnectTimeout - | WebsocketFallbackReason::FirstResponseTimeout - | WebsocketFallbackReason::ConnectFailed - | WebsocketFallbackReason::StreamClosedEarly - | WebsocketFallbackReason::WebsocketError => ( - (WEBSOCKET_MODEL_COOLDOWN_BASE_SECS / 2).max(1), - (WEBSOCKET_MODEL_COOLDOWN_MAX_SECS / 2).max(1), - ), - } -} - -pub(super) fn normalize_transport_model(model: &str) -> Option { - let normalized = model.trim().to_ascii_lowercase(); - if normalized.is_empty() { - None - } else { - Some(normalized) - } -} - -pub(super) async fn websocket_cooldown_remaining( - websocket_cooldowns: &Arc>>, - model: &str, -) -> Option { - let key = normalize_transport_model(model)?; - let now = Instant::now(); - - { - let guard = websocket_cooldowns.read().await; - if let Some(until) = guard.get(&key) - && *until > now - { - return Some(*until - now); - } - } - - let mut guard = websocket_cooldowns.write().await; - if let Some(until) = guard.get(&key) - && *until > now - { - return Some(*until - now); - } - if guard.get(&key).is_some() { - guard.remove(&key); - } - None -} #[cfg(test)] -pub(super) async fn set_websocket_cooldown( - websocket_cooldowns: &Arc>>, - model: &str, -) { - let Some(key) = normalize_transport_model(model) else { - return; - }; - - let cooldown = Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS); - let until = Instant::now() + cooldown; - let mut guard = websocket_cooldowns.write().await; - guard.insert(key, until); -} - -pub(super) async fn set_websocket_cooldown_for( - websocket_cooldowns: &Arc>>, - model: &str, - cooldown: Duration, -) { - let Some(key) = normalize_transport_model(model) else { - return; - }; - - let until = Instant::now() + cooldown; - let mut guard = websocket_cooldowns.write().await; - guard.insert(key, until); -} - -pub(super) async fn clear_websocket_cooldown( - websocket_cooldowns: &Arc>>, - model: &str, -) { - let Some(key) = normalize_transport_model(model) else { - return; - }; - - let mut guard = websocket_cooldowns.write().await; - guard.remove(&key); -} - -pub(super) fn websocket_cooldown_for_streak( - streak: u32, - reason: WebsocketFallbackReason, -) -> Duration { - let (base, max) = websocket_cooldown_bounds_for_reason(reason); - let base = base as u128; - let max = max as u128; - let shift = streak.saturating_sub(1).min(16); - let scaled = base.saturating_mul(1u128 << shift); - Duration::from_secs(scaled.min(max) as u64) -} - -pub(super) async fn record_websocket_fallback( - websocket_cooldowns: &Arc>>, - websocket_failure_streaks: &Arc>>, - model: &str, - reason: WebsocketFallbackReason, -) -> (u32, Duration) { - let Some(key) = normalize_transport_model(model) else { - return (0, websocket_cooldown_for_streak(1, reason)); - }; - - let streak = { - let mut guard = websocket_failure_streaks.write().await; - let entry = guard.entry(key).or_insert(0); - *entry = entry.saturating_add(1); - *entry - }; - - let cooldown = websocket_cooldown_for_streak(streak, reason); - set_websocket_cooldown_for(websocket_cooldowns, model, cooldown).await; - (streak, cooldown) -} - -pub(super) async fn record_websocket_success( - websocket_cooldowns: &Arc>>, - websocket_failure_streaks: &Arc>>, - model: &str, -) { - clear_websocket_cooldown(websocket_cooldowns, model).await; - let Some(key) = normalize_transport_model(model) else { - return; - }; - let streak = { - let mut guard = websocket_failure_streaks.write().await; - guard.remove(&key).unwrap_or(0) - }; - if streak > 0 { - crate::logging::info(&format!( - "OpenAI websocket health reset for model='{}' after successful stream (previous streak={})", - model, streak - )); - } -} +pub(super) use jcode_provider_openai::websocket_health::{ + WebsocketFallbackReason, clear_websocket_cooldown, normalize_transport_model, + set_websocket_cooldown, websocket_cooldown_for_streak, websocket_remaining_timeout_secs, +}; diff --git a/crates/jcode-base/src/telemetry/tests.rs b/crates/jcode-base/src/telemetry/tests.rs index f204793f5..581ba140a 100644 --- a/crates/jcode-base/src/telemetry/tests.rs +++ b/crates/jcode-base/src/telemetry/tests.rs @@ -30,12 +30,25 @@ fn test_do_not_track() { fn test_is_ci_detects_ci_env() { let _guard = lock_test_env(); // Clear any inherited CI markers so the baseline is deterministic. - for key in ["CI", "GITHUB_ACTIONS", "BUILDKITE", "JENKINS_URL", "GITLAB_CI", "CIRCLECI"] { + for key in [ + "CI", + "GITHUB_ACTIONS", + "BUILDKITE", + "JENKINS_URL", + "GITLAB_CI", + "CIRCLECI", + ] { crate::env::remove_var(key); } - assert!(!is_ci(), "expected non-CI baseline after clearing CI markers"); + assert!( + !is_ci(), + "expected non-CI baseline after clearing CI markers" + ); crate::env::set_var("CI", "true"); - assert!(is_ci(), "CI env var should mark the run as CI (gates install skip)"); + assert!( + is_ci(), + "CI env var should mark the run as CI (gates install skip)" + ); crate::env::remove_var("CI"); assert!(!is_ci()); } diff --git a/crates/jcode-build-support/src/lib.rs b/crates/jcode-build-support/src/lib.rs index ad8cc2013..27ceff98a 100644 --- a/crates/jcode-build-support/src/lib.rs +++ b/crates/jcode-build-support/src/lib.rs @@ -818,6 +818,14 @@ pub fn repair_stale_shared_server_channel() -> Result { if previous.as_deref().map(str::trim).filter(|s| !s.is_empty()) == Some(stable_version) { return Ok(SharedServerRepair::AlreadyCurrent); } + if previous + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .is_some_and(|previous| !is_release_channel_marker(previous)) + { + return Ok(SharedServerRepair::AlreadyCurrent); + } // Only repair when stable is strictly newer than the current shared-server // binary on disk. This never downgrades, and it preserves a self-dev pin @@ -838,6 +846,15 @@ pub fn repair_stale_shared_server_channel() -> Result { }) } +fn is_release_channel_marker(marker: &str) -> bool { + let marker = marker.trim(); + let marker = marker.strip_prefix('v').unwrap_or(marker); + marker.starts_with("main-") + || marker + .split('.') + .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())) +} + /// True when `shared` exists and is strictly older (by mtime) than `stable`, or /// when `shared` is missing entirely (nothing to protect). Any mtime /// uncertainty on an existing shared binary is treated as "not older" so we diff --git a/crates/jcode-build-support/src/tests.rs b/crates/jcode-build-support/src/tests.rs index 88af9652f..739346d94 100644 --- a/crates/jcode-build-support/src/tests.rs +++ b/crates/jcode-build-support/src/tests.rs @@ -806,6 +806,30 @@ fn repair_preserves_fresher_selfdev_pin() { }); } +#[test] +fn repair_preserves_older_selfdev_pin() { + use std::time::{Duration, SystemTime}; + with_temp_jcode_home(|| { + let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000); + let selfdev_old = "56f43c3d-dirty-deadbeef"; + let stable_new = "0.22.0"; + write_versioned_binary(selfdev_old, base); + write_versioned_binary(stable_new, base + Duration::from_secs(120)); + update_shared_server_symlink(selfdev_old).expect("pin older self-dev"); + update_stable_symlink(stable_new).expect("stable new"); + + assert_eq!( + repair_stale_shared_server_channel().expect("repair"), + SharedServerRepair::AlreadyCurrent, + "repair must not overwrite a deliberately-pinned self-dev build" + ); + assert_eq!( + read_shared_server_version().unwrap().as_deref(), + Some(selfdev_old), + ); + }); +} + #[test] fn repair_never_downgrades_when_stable_is_older() { use std::time::{Duration, SystemTime}; diff --git a/crates/jcode-provider-openai/Cargo.toml b/crates/jcode-provider-openai/Cargo.toml index 6054e881a..f9392aca7 100644 --- a/crates/jcode-provider-openai/Cargo.toml +++ b/crates/jcode-provider-openai/Cargo.toml @@ -16,3 +16,4 @@ jcode-provider-core = { path = "../jcode-provider-core" } reqwest = { version = "0.12", default-features = false, features = ["stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1", features = ["sync"] } diff --git a/crates/jcode-provider-openai/src/lib.rs b/crates/jcode-provider-openai/src/lib.rs index 2c2028917..81ea2650a 100644 --- a/crates/jcode-provider-openai/src/lib.rs +++ b/crates/jcode-provider-openai/src/lib.rs @@ -1,5 +1,6 @@ pub mod request; pub mod stream; +pub mod websocket_health; pub use request::{ OPENAI_ENCRYPTED_CONTENT_PROVIDER_MAX_CHARS, OPENAI_ENCRYPTED_CONTENT_SAFE_MAX_CHARS, diff --git a/crates/jcode-provider-openai/src/request.rs b/crates/jcode-provider-openai/src/request.rs index 69949a766..12ae14149 100644 --- a/crates/jcode-provider-openai/src/request.rs +++ b/crates/jcode-provider-openai/src/request.rs @@ -258,7 +258,9 @@ pub fn build_responses_input_with_logger( } items.push(item); } - ContentBlock::ToolUse { id, name, input, .. } => { + ContentBlock::ToolUse { + id, name, input, .. + } => { let arguments = if input.is_object() { serde_json::to_string(&input).unwrap_or_default() } else { diff --git a/crates/jcode-provider-openai/src/websocket_health.rs b/crates/jcode-provider-openai/src/websocket_health.rs new file mode 100644 index 000000000..43d283d27 --- /dev/null +++ b/crates/jcode-provider-openai/src/websocket_health.rs @@ -0,0 +1,290 @@ +use jcode_message_types::StreamEvent; + +pub const WEBSOCKET_FALLBACK_NOTICE: &str = "falling back from websockets to https transport"; +pub const WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS: u64 = 8; +pub const WEBSOCKET_COMPLETION_TIMEOUT_SECS: u64 = 300; +pub const WEBSOCKET_MODEL_COOLDOWN_BASE_SECS: u64 = 60; +pub const WEBSOCKET_MODEL_COOLDOWN_MAX_SECS: u64 = 600; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WebsocketFallbackReason { + ConnectTimeout, + FirstResponseTimeout, + StreamTimeout, + ServerRequestedHttps, + ConnectFailed, + StreamClosedEarly, + WebsocketError, +} + +impl WebsocketFallbackReason { + pub fn summary(self) -> &'static str { + match self { + Self::ConnectTimeout => "connect timeout", + Self::FirstResponseTimeout => "first response timeout", + Self::StreamTimeout => "stream timeout", + Self::ServerRequestedHttps => "server requested https", + Self::ConnectFailed => "connect failed", + Self::StreamClosedEarly => "stream closed early", + Self::WebsocketError => "websocket error", + } + } +} + +pub fn is_websocket_fallback_notice(data: &str) -> bool { + // The proxy injects the fallback notice as a plain-text control frame, not + // a structured Responses API event. A legitimate `response.*`/`error` + // event can legitimately *contain* this phrase (for example inside + // tool-call arguments when the model is editing source that mentions + // websocket fallback), so a structured event must never be reinterpreted + // as a transport control frame. + if is_structured_response_event(data) { + return false; + } + data.to_lowercase().contains(WEBSOCKET_FALLBACK_NOTICE) +} + +pub fn is_stream_activity_event(_event: &StreamEvent) -> bool { + true +} + +/// Returns true when `data` parses as a structured Responses API stream event +/// (a JSON object whose `type` is a `response.*` event or a top-level `error`). +/// These frames carry model output and must be parsed as protocol events even +/// if their content happens to contain transport-control phrases. +pub fn is_structured_response_event(data: &str) -> bool { + is_websocket_activity_payload(data) +} + +pub fn is_websocket_activity_payload(data: &str) -> bool { + let Ok(value) = serde_json::from_str::(data) else { + return false; + }; + let Some(kind) = value.get("type").and_then(|kind| kind.as_str()) else { + return false; + }; + kind.starts_with("response.") || kind == "error" +} + +pub fn is_websocket_first_activity_payload(data: &str) -> bool { + let Ok(value) = serde_json::from_str::(data) else { + return false; + }; + value + .get("type") + .and_then(|kind| kind.as_str()) + .map(|kind| !kind.is_empty()) + .unwrap_or(false) +} + +pub fn websocket_remaining_timeout_secs(since: Instant, timeout_secs: u64) -> Option { + let timeout = Duration::from_secs(timeout_secs); + let elapsed = since.elapsed(); + if elapsed >= timeout { + return None; + } + + Some(timeout_secs.saturating_sub(elapsed.as_secs()).max(1)) +} + +pub fn websocket_next_activity_timeout_secs( + ws_started_at: Instant, + last_api_activity_at: Instant, + saw_api_activity: bool, +) -> Option { + if !saw_api_activity { + websocket_remaining_timeout_secs(ws_started_at, WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS) + } else { + websocket_remaining_timeout_secs(last_api_activity_at, WEBSOCKET_COMPLETION_TIMEOUT_SECS) + } +} + +pub fn websocket_activity_timeout_kind(saw_api_activity: bool) -> &'static str { + if saw_api_activity { "next" } else { "first" } +} + +pub fn classify_websocket_fallback_reason(error: &str) -> WebsocketFallbackReason { + let error = error.to_ascii_lowercase(); + if error.contains("connect timed out") { + WebsocketFallbackReason::ConnectTimeout + } else if error.contains("did not emit api activity within") + || error.contains("timed out waiting for first websocket activity") + { + WebsocketFallbackReason::FirstResponseTimeout + } else if error.contains("timed out waiting for next websocket activity") + || error.contains("did not complete within") + { + WebsocketFallbackReason::StreamTimeout + } else if error.contains("upgrade required") + || error.contains("server requested fallback") + || error.contains(WEBSOCKET_FALLBACK_NOTICE) + { + WebsocketFallbackReason::ServerRequestedHttps + } else if error.contains("failed to connect websocket stream") { + WebsocketFallbackReason::ConnectFailed + } else if error.contains("ended before response.completed") + || error.contains("closed before response.completed") + { + WebsocketFallbackReason::StreamClosedEarly + } else { + WebsocketFallbackReason::WebsocketError + } +} + +pub fn summarize_websocket_fallback_reason(error: &str) -> &'static str { + classify_websocket_fallback_reason(error).summary() +} + +fn websocket_cooldown_bounds_for_reason(reason: WebsocketFallbackReason) -> (u64, u64) { + match reason { + WebsocketFallbackReason::ServerRequestedHttps => ( + WEBSOCKET_MODEL_COOLDOWN_BASE_SECS.saturating_mul(5), + WEBSOCKET_MODEL_COOLDOWN_MAX_SECS.saturating_mul(3), + ), + WebsocketFallbackReason::StreamTimeout => ( + WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, + WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, + ), + WebsocketFallbackReason::ConnectTimeout + | WebsocketFallbackReason::FirstResponseTimeout + | WebsocketFallbackReason::ConnectFailed + | WebsocketFallbackReason::StreamClosedEarly + | WebsocketFallbackReason::WebsocketError => ( + (WEBSOCKET_MODEL_COOLDOWN_BASE_SECS / 2).max(1), + (WEBSOCKET_MODEL_COOLDOWN_MAX_SECS / 2).max(1), + ), + } +} + +pub fn normalize_transport_model(model: &str) -> Option { + let normalized = model.trim().to_ascii_lowercase(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +pub async fn websocket_cooldown_remaining( + websocket_cooldowns: &Arc>>, + model: &str, +) -> Option { + let key = normalize_transport_model(model)?; + let now = Instant::now(); + + { + let guard = websocket_cooldowns.read().await; + if let Some(until) = guard.get(&key) + && *until > now + { + return Some(*until - now); + } + } + + let mut guard = websocket_cooldowns.write().await; + if let Some(until) = guard.get(&key) + && *until > now + { + return Some(*until - now); + } + if guard.get(&key).is_some() { + guard.remove(&key); + } + None +} + +pub async fn set_websocket_cooldown( + websocket_cooldowns: &Arc>>, + model: &str, +) { + let Some(key) = normalize_transport_model(model) else { + return; + }; + + let cooldown = Duration::from_secs(WEBSOCKET_MODEL_COOLDOWN_BASE_SECS); + let until = Instant::now() + cooldown; + let mut guard = websocket_cooldowns.write().await; + guard.insert(key, until); +} + +pub async fn set_websocket_cooldown_for( + websocket_cooldowns: &Arc>>, + model: &str, + cooldown: Duration, +) { + let Some(key) = normalize_transport_model(model) else { + return; + }; + + let until = Instant::now() + cooldown; + let mut guard = websocket_cooldowns.write().await; + guard.insert(key, until); +} + +pub async fn clear_websocket_cooldown( + websocket_cooldowns: &Arc>>, + model: &str, +) { + let Some(key) = normalize_transport_model(model) else { + return; + }; + + let mut guard = websocket_cooldowns.write().await; + guard.remove(&key); +} + +pub fn websocket_cooldown_for_streak(streak: u32, reason: WebsocketFallbackReason) -> Duration { + let (base, max) = websocket_cooldown_bounds_for_reason(reason); + let base = base as u128; + let max = max as u128; + let shift = streak.saturating_sub(1).min(16); + let scaled = base.saturating_mul(1u128 << shift); + Duration::from_secs(scaled.min(max) as u64) +} + +pub async fn record_websocket_fallback( + websocket_cooldowns: &Arc>>, + websocket_failure_streaks: &Arc>>, + model: &str, + reason: WebsocketFallbackReason, +) -> (u32, Duration) { + let Some(key) = normalize_transport_model(model) else { + return (0, websocket_cooldown_for_streak(1, reason)); + }; + + let streak = { + let mut guard = websocket_failure_streaks.write().await; + let entry = guard.entry(key).or_insert(0); + *entry = entry.saturating_add(1); + *entry + }; + + let cooldown = websocket_cooldown_for_streak(streak, reason); + set_websocket_cooldown_for(websocket_cooldowns, model, cooldown).await; + (streak, cooldown) +} + +pub async fn record_websocket_success( + websocket_cooldowns: &Arc>>, + websocket_failure_streaks: &Arc>>, + model: &str, +) { + clear_websocket_cooldown(websocket_cooldowns, model).await; + let Some(key) = normalize_transport_model(model) else { + return; + }; + let streak = { + let mut guard = websocket_failure_streaks.write().await; + guard.remove(&key).unwrap_or(0) + }; + if streak > 0 { + jcode_logging::info(&format!( + "OpenAI websocket health reset for model='{}' after successful stream (previous streak={})", + model, streak + )); + } +} diff --git a/crates/jcode-tui/src/tui/app/remote/server_events.rs b/crates/jcode-tui/src/tui/app/remote/server_events.rs index f31eeacd0..3e9fc3b71 100644 --- a/crates/jcode-tui/src/tui/app/remote/server_events.rs +++ b/crates/jcode-tui/src/tui/app/remote/server_events.rs @@ -313,14 +313,16 @@ pub(in crate::tui::app) fn handle_server_event( if let Some(chunk) = app.stream_buffer.flush() { app.append_streaming_text(&chunk); } - if matches!( - app.status, - ProcessingStatus::Sending - | ProcessingStatus::Connecting(_) - | ProcessingStatus::Thinking(_) - ) || (app.is_processing && matches!(app.status, ProcessingStatus::Idle)) - { - app.status = ProcessingStatus::Streaming; + // Surface active reasoning in the status line. The server emits a + // `ConnectionPhase::Streaming` when reasoning starts (to kick off the + // client TPS timer), so the status arrives here as `Streaming`; flip it + // to `Thinking` while reasoning deltas flow. The next `TextDelta` moves + // it back to `Streaming`. + if !matches!(app.status, ProcessingStatus::RunningTool(_)) { + let thinking_start = *app.thinking_start.get_or_insert_with(Instant::now); + if !matches!(app.status, ProcessingStatus::Thinking(_)) { + app.status = ProcessingStatus::Thinking(thinking_start); + } } app.resume_streaming_tps(); app.append_reasoning_text(&text); @@ -328,6 +330,7 @@ pub(in crate::tui::app) fn handle_server_event( eager_stream_redraw } ServerEvent::ReasoningDone { .. } => { + app.thinking_start = None; app.close_reasoning_region(None); eager_stream_redraw } diff --git a/crates/jcode-tui/src/tui/app/turn.rs b/crates/jcode-tui/src/tui/app/turn.rs index f0c9a6482..37d176af6 100644 --- a/crates/jcode-tui/src/tui/app/turn.rs +++ b/crates/jcode-tui/src/tui/app/turn.rs @@ -717,6 +717,17 @@ impl App { } StreamEvent::ThinkingDelta(thinking_text) => { self.resume_streaming_tps(); + // Reflect active reasoning in the status line even when the + // provider streams reasoning deltas without an explicit + // ThinkingStart (e.g. OpenRouter, Bedrock) or when the + // reasoning text itself is hidden by config. + let thinking_start = + *self.thinking_start.get_or_insert_with(Instant::now); + let entered_thinking = + !matches!(self.status, ProcessingStatus::Thinking(_)); + if entered_thinking { + self.status = ProcessingStatus::Thinking(thinking_start); + } // Buffer thinking content for status/debug accounting. self.thinking_buffer.push_str(&thinking_text); // Flush any pending real output before reasoning text. @@ -732,6 +743,12 @@ impl App { // persisted as a history-only trace, regardless // of provider replay support. reasoning_content.push_str(&thinking_text); + // When reasoning text is hidden, the status flip to + // "thinking…" is the only visible signal, so repaint + // promptly on the first delta. + if entered_thinking && eager_stream_redraw { + status_spinner_renderer.draw_full(self, terminal)?; + } } StreamEvent::ThinkingEnd => { self.pause_streaming_tps(true); diff --git a/src/cli/hot_exec.rs b/src/cli/hot_exec.rs index 604587382..59fe44547 100644 --- a/src/cli/hot_exec.rs +++ b/src/cli/hot_exec.rs @@ -152,6 +152,7 @@ pub fn hot_update(session_id: &str) -> Result<()> { }) { Ok(path) => { update::print_centered(&format!("✓ Installed {}", release.tag_name)); + reload_server_after_update("installed update"); let is_selfdev = crate::cli::selfdev::client_selfdev_requested(); let exe = build::client_update_candidate(is_selfdev) @@ -180,6 +181,9 @@ pub fn hot_update(session_id: &str) -> Result<()> { } } Ok(None) => { + if repair_stale_shared_server_after_update_check() { + reload_server_after_update("repaired stale server target"); + } update::print_centered(&format!( "Already up to date ({})", jcode_build_meta::VERSION @@ -317,9 +321,13 @@ pub fn run_update() -> Result<()> { )); })?; update::print_centered(&format!("✅ Updated to {}", release.tag_name)); + reload_server_after_update("installed update"); update::print_centered("Restart jcode to use the new version."); } Ok(None) => { + if repair_stale_shared_server_after_update_check() { + reload_server_after_update("repaired stale server target"); + } update::print_centered(&format!( "Already up to date ({})", jcode_build_meta::VERSION @@ -364,3 +372,66 @@ pub fn run_update() -> Result<()> { Ok(()) } + +fn repair_stale_shared_server_after_update_check() -> bool { + match build::repair_stale_shared_server_channel() { + Ok(build::SharedServerRepair::Repaired { + previous, + repaired_to, + }) => { + crate::logging::info(&format!( + "update: repaired stale shared-server channel {:?} -> {}", + previous, repaired_to + )); + update::print_centered(&format!( + "Repaired stale server reload target: {}", + repaired_to + )); + true + } + Ok(build::SharedServerRepair::AlreadyCurrent) => false, + Err(error) => { + crate::logging::warn(&format!( + "update: failed to repair stale shared-server channel: {}", + error + )); + false + } + } +} + +fn reload_server_after_update(reason: &str) { + let exe = build::client_update_candidate(false) + .map(|(path, _)| path) + .or_else(|| std::env::current_exe().ok()); + let Some(exe) = exe else { + crate::logging::warn("update: could not find jcode binary to reload stale server"); + return; + }; + + let output = ProcessCommand::new(&exe) + .args(["--no-update", "server", "reload", "--force"]) + .output(); + match output { + Ok(output) if output.status.success() => { + crate::logging::info(&format!( + "update: requested server reload after {} via {:?}", + reason, exe + )); + } + Ok(output) => { + crate::logging::warn(&format!( + "update: server reload after {} failed with status {:?}: {}", + reason, + output.status.code(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Err(error) => { + crate::logging::warn(&format!( + "update: failed to request server reload after {} via {:?}: {}", + reason, exe, error + )); + } + } +} From 4f220e21204954dd91ac5deccd6faea600d5e4a1 Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:12:59 -0700 Subject: [PATCH 40/41] test(tui): cover reasoning-delta thinking status in status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live reasoning now surfaces as a 'thinking…' status instead of 'streaming…'. Cover the remote ReasoningDelta path: it flips the status to Thinking, a subsequent TextDelta returns to Streaming, and a running tool is never masked by reasoning text. --- .../tests/remote_events_reload_01/part_01.rs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs index 411796e57..c0c4d80e7 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_events_reload_01/part_01.rs @@ -1132,3 +1132,56 @@ fn test_handle_server_event_mcp_status_updates_tools_without_status_notice() { assert_eq!(app.mcp_server_names, vec![("agentcard".to_string(), 8)]); assert_eq!(app.status_notice(), None); } + +#[test] +fn test_handle_server_event_reasoning_delta_shows_thinking_status() { + let mut app = create_test_app(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + let mut remote = crate::tui::backend::RemoteConnection::dummy(); + + app.is_processing = true; + // Server emits ConnectionPhase::Streaming when reasoning starts (to kick the + // TPS timer), so the status arrives as Streaming. + app.status = ProcessingStatus::Streaming; + + app.handle_server_event( + crate::protocol::ServerEvent::ReasoningDelta { + text: "weighing options".to_string(), + }, + &mut remote, + ); + + // Live reasoning should read as "thinking", not "streaming". + assert!(matches!(app.status, ProcessingStatus::Thinking(_))); + + // Real output text flips the status back to streaming. + app.handle_server_event( + crate::protocol::ServerEvent::TextDelta { + text: "Here is the answer".to_string(), + }, + &mut remote, + ); + assert!(matches!(app.status, ProcessingStatus::Streaming)); +} + +#[test] +fn test_handle_server_event_reasoning_delta_keeps_tool_status() { + let mut app = create_test_app(); + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + let mut remote = crate::tui::backend::RemoteConnection::dummy(); + + app.is_processing = true; + app.status = ProcessingStatus::RunningTool("bash".to_string()); + + app.handle_server_event( + crate::protocol::ServerEvent::ReasoningDelta { + text: "post-tool reflection".to_string(), + }, + &mut remote, + ); + + // A running tool must not be masked by reasoning text. + assert!(matches!(app.status, ProcessingStatus::RunningTool(_))); +} From f759ea7cec48681d8eab35b58e33ab464efeae7b Mon Sep 17 00:00:00 2001 From: jeremy <94247773+1jehuang@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:15:37 -0700 Subject: [PATCH 41/41] refactor(provider): cfg-gate OpenAI websocket test exports --- crates/jcode-base/src/provider/openai.rs | 5 +++-- .../src/provider/openai/websocket_health.rs | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/jcode-base/src/provider/openai.rs b/crates/jcode-base/src/provider/openai.rs index f31ba4fb1..b0bf0f935 100644 --- a/crates/jcode-base/src/provider/openai.rs +++ b/crates/jcode-base/src/provider/openai.rs @@ -1019,8 +1019,9 @@ use self::websocket_health::{ }; #[cfg(test)] use self::websocket_health::{ - WebsocketFallbackReason, clear_websocket_cooldown, normalize_transport_model, - set_websocket_cooldown, websocket_cooldown_for_streak, websocket_remaining_timeout_secs, + WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, WebsocketFallbackReason, + clear_websocket_cooldown, normalize_transport_model, set_websocket_cooldown, + websocket_cooldown_for_streak, websocket_remaining_timeout_secs, }; #[cfg(test)] diff --git a/crates/jcode-base/src/provider/openai/websocket_health.rs b/crates/jcode-base/src/provider/openai/websocket_health.rs index 55fe1944b..e486285b2 100644 --- a/crates/jcode-base/src/provider/openai/websocket_health.rs +++ b/crates/jcode-base/src/provider/openai/websocket_health.rs @@ -1,15 +1,15 @@ pub(super) use jcode_provider_openai::websocket_health::{ WEBSOCKET_COMPLETION_TIMEOUT_SECS, WEBSOCKET_FALLBACK_NOTICE, - WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, - WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, classify_websocket_fallback_reason, - is_stream_activity_event, is_structured_response_event, is_websocket_activity_payload, - is_websocket_fallback_notice, is_websocket_first_activity_payload, record_websocket_fallback, - record_websocket_success, summarize_websocket_fallback_reason, websocket_activity_timeout_kind, + WEBSOCKET_FIRST_EVENT_TIMEOUT_SECS, classify_websocket_fallback_reason, + is_stream_activity_event, is_websocket_activity_payload, is_websocket_fallback_notice, + is_websocket_first_activity_payload, record_websocket_fallback, record_websocket_success, + summarize_websocket_fallback_reason, websocket_activity_timeout_kind, websocket_cooldown_remaining, websocket_next_activity_timeout_secs, }; #[cfg(test)] pub(super) use jcode_provider_openai::websocket_health::{ - WebsocketFallbackReason, clear_websocket_cooldown, normalize_transport_model, - set_websocket_cooldown, websocket_cooldown_for_streak, websocket_remaining_timeout_secs, + WEBSOCKET_MODEL_COOLDOWN_BASE_SECS, WEBSOCKET_MODEL_COOLDOWN_MAX_SECS, WebsocketFallbackReason, + clear_websocket_cooldown, normalize_transport_model, set_websocket_cooldown, + websocket_cooldown_for_streak, websocket_remaining_timeout_secs, };