From 35db361a87ac20fcf1cbbe1059512717a4dd79fc Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 21:46:25 -0500 Subject: [PATCH] v0.7.2: clippy clean, cost counter wiring, layered context fixup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #167: Fix all 7 clippy warnings — annotated SeamMetadata dead fields, removed unused should_cycle calls, collapsed nested ifs, fixed useless_format and nonminimal_bool. #168: Wire TokenUsage mailbox drain to subagent_cost accumulator. handle_subagent_mailbox now intercepts TokenUsage before routing to cards, computes cost via calculate_turn_cost, and increments app.subagent_cost in real time. Footer reflects live sub-agent spend. Restored ArchivedContext variant to HistoryCell (corrupted by prior apply_patch). Version bump to 0.7.2. Refs: #166, #167, #168 --- Cargo.lock | 28 +- Cargo.toml | 2 +- assets/Screenshot 2026-04-28 at 21.12.34.png | Bin 0 -> 85993 bytes crates/tools/src/lib.rs | 18 +- crates/tui/src/client.rs | 6 +- crates/tui/src/client.rs.bak2 | 2213 ++++++ crates/tui/src/commands/session.rs | 9 + crates/tui/src/config.rs | 123 +- crates/tui/src/core/engine.rs | 249 +- crates/tui/src/core/engine.rs.bak | 2853 ++++++++ crates/tui/src/core/engine/turn_loop.rs | 6 + crates/tui/src/cycle_manager.rs | 24 +- crates/tui/src/cycle_manager.rs.bak3 | 1014 +++ crates/tui/src/main.rs | 10 + crates/tui/src/prompts/base.md | 38 +- crates/tui/src/seam_manager.rs | 20 +- crates/tui/src/tools/file.rs | 35 +- crates/tui/src/tools/subagent/mailbox.rs | 32 +- crates/tui/src/tools/subagent/mod.rs | 10 + crates/tui/src/tui/app.rs | 3 + crates/tui/src/tui/history.rs | 205 +- crates/tui/src/tui/provider_picker.rs | 6 +- crates/tui/src/tui/transcript.rs | 1 + crates/tui/src/tui/ui.rs | 36 +- crates/tui/src/tui/ui.rs.bak3 | 6635 ++++++++++++++++++ crates/tui/src/tui/widgets/agent_card.rs | 12 + crates/tui/src/tui/widgets/footer.rs | 69 +- 27 files changed, 13550 insertions(+), 107 deletions(-) create mode 100644 assets/Screenshot 2026-04-28 at 21.12.34.png create mode 100644 crates/tui/src/client.rs.bak2 create mode 100644 crates/tui/src/core/engine.rs.bak create mode 100644 crates/tui/src/cycle_manager.rs.bak3 create mode 100644 crates/tui/src/tui/ui.rs.bak3 diff --git a/Cargo.lock b/Cargo.lock index d214f9ad..cc1dadca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,7 +1011,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.7.1" +version = "0.7.2" dependencies = [ "deepseek-config", "serde", @@ -1019,7 +1019,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "axum", @@ -1042,7 +1042,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "deepseek-secrets", @@ -1055,7 +1055,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "chrono", @@ -1074,7 +1074,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "deepseek-protocol", @@ -1083,7 +1083,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "async-trait", @@ -1097,7 +1097,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "deepseek-protocol", @@ -1107,7 +1107,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.7.1" +version = "0.7.2" dependencies = [ "serde", "serde_json", @@ -1115,7 +1115,7 @@ dependencies = [ [[package]] name = "deepseek-secrets" -version = "0.7.1" +version = "0.7.2" dependencies = [ "dirs", "keyring", @@ -1128,7 +1128,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "chrono", @@ -1140,7 +1140,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "async-trait", @@ -1153,7 +1153,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "arboard", @@ -1213,7 +1213,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "chrono", @@ -1236,7 +1236,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.7.1" +version = "0.7.2" [[package]] name = "deranged" diff --git a/Cargo.toml b/Cargo.toml index ce1c4fea..b114a9b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.7.1" +version = "0.7.2" edition = "2024" license = "MIT" repository = "https://github.com/Hmbown/DeepSeek-TUI" diff --git a/assets/Screenshot 2026-04-28 at 21.12.34.png b/assets/Screenshot 2026-04-28 at 21.12.34.png new file mode 100644 index 0000000000000000000000000000000000000000..11d64e53cb94fe55f39e3cac9d5b8996f1ef2559 GIT binary patch literal 85993 zcmafa19TaN;V zy{oqB+tnceX;B1N99R$#5CpLwLUJG=;Ey06piZBlKAybkghhaWz&4u*3IfCg1&IK* zR)!|#1|T3mLK2do6;M?%y*DLY^B|+8kEC4Th)8b$`O2~|V%`7&cz;7dVT~SiVIhb) zNX>ZSW=i1&UzO@=^1Du=Fz;Gg4D;+4a6I3(_s945m-}?K`!vq`b+=8<>w`5=ZvlN6 z2#RVnkdha8-RdMN8b-?0KsT5vKI|bdsq8VmA~-A`A5V}Nmsh7pYeYV#r^ersX{Vn#*8?kL#u=05b`Aae};l?e%7lD9cOjl#x&Arqu*`?F`JZa1rn zNQ^)EL$!S=M*ztj(}O_;Axit=sP>H$G(<3S*x=XU)x)R5FK6+oslWJ}QFT4~MZK>i z-Cft5Y*5}Xn56OI2E>uB*VGIlFDE20^Q&#&CeI%>V^fJ!Cv$?}ZF8qMv8G{%GGAlT z52y@1*Jiw34r~lxjo4-P@L4C%?3<2Uq-9`qCzyivFQ_8pYUivncU>~|2(_S?>@!=A z3BFuYOTu9sMqsuw9HfwH6!XX9eQPAMP1$*93CTgeP9KKTrmUo4?|14MlrqN*^&K$V z26kv*0$oGzzp%WvHC!j}v|xwiZ0vaG(9BKlGhyfziiVo)TCa0sxcBzI`NAvP(DI9C zFJ-0ekTw<>1qL4)644#@uJQdQ_*UZ%c8L7|O2-GT8~hov$J=Mkg=ldL-qP{cnBLk9 z1iOHifF4wYPx^P-h+(5N>O$3I)g=&mJI_O{C@229LzLJ{)oK zA;zpCoY@`Iy2h-o=&ty#xUFCw5bL3x!JQEVd{KE_y5v~s;mE%T`uc_RQ0P^Y!6dN@ zF=l;R{9D4Xd)>SBYJe)-@IewHb1vEqpE_k6-x{=wo!mt-mfvD2UgK zuf;mkh(*uE{QOZFJ(fTn$MwZU&{i^(sDNt!+d!-unIeUQ_(|uSKeOVB1_vL9qHCIc z$Bp7KGIn}+Wq1(0=r`?y>@mm`e#+2NVTZihlC6DVd#HQOi_N1OP#dF!R*!mzcBQr0WV%9_5Ir7U1Fac0 z0CpVK3B8ki28|mn4&8~ek%C82fC`%ulEQ%ET)8!$xsbZ-EPG6eR)MGTk=k#y0yRyw-IbG-w-vK`+N+d z5Dqu{RtKJwlVg!1%V~>Q{Au|q{geF@mLu-zq9e}Js+p?kE`4v7Y}~K7XH3l|pK#T1 zzu_q1Y|#5gn1?%yJcmDF=wqzX9MDy&x^E_%kI_#sThbHI$FTioS~i|B1gl^$WXjrkj?M^j4 zx9S*Z9epZ`sEC*<95$R&T4h~m9J%AsOeFZRDbl4ERAbkmZsoLcQP&aS5%_BJ+=Um8 zAB%s2FOqmfvrWrh|8q)qHfCSteCbliF4W;^xMWkcFJ)h2_hn6ZG++5u1e7o z+W-`z{>Dn&$m1A_-d(CI)z7e7y7Oz<@{IdT84pvM!YKZ9h-UlqAm^{7{;03tznzI= zN!d$NOW^Ic{HAoaifxbk{MAG9^ADbMt_+lzne zH^sX$$0{7mCs?!DoSXgQbL`q5d2=K)m=+iaKRFDP3_V9+CplthGR7w_h8f0|Q<>6F zQ=YF)Z%pq_Dp_#MSj(|hMpe{1KC59iRho4yJiXisx@ftGvLdm4wR*6~TmBUramglZ zelnyyJ~>LbcGl36!2zS%O0>8Iu<$-TG&|Ar1?Dk zG6*CrY8te)PFrKFkmm&zG>3fO2}gG^Hs>z)0-e?6AF`XDq7)cMEnC za(fzm9a+6MyrQ+pB5JBqo1o!=N0n0zhSevt4Vf5mMVRcUT&Jxh}I}j!CtXz5n}OrE_?1# zXLanTYY&40M`=zr~}N!3Q69o%kxs5dpR zA<>yssd3lto|881aS^(kFspoCmDwuNwcxmZ;A$4-ATw&U(A99Wa?`j`-7)PsdtoTL z8QWRclx~&Lb?*5L#6QS5bxPVeZ^LxF?0HRoentF_C_+%k<$g_mJ-xTvPL@m-AXAW; z$Sv=pblui-IsVG}Y6P_g?V1_H<;68b;C?ImL?|!#NJmaL5;z}pDnczH9pxIi;ofq# zKSkA3HeBP&Tku})TJiq0r%mg18!j4&*n!-c;@NhdvD4OfS+U_H1tHD#9Q~vV)ZZo> zkP+pX<#qB3d0^y9VF^g1d^f#n*iE}^BAYFlodrvbW?6+LS_k#s-34`a!vYaU1DQ($ zK}s6GU91Bee?=Wbd63~+^qYj8X{rAXzGX)Mk~a==vjsY}6naCn4gw(u@~Hv5x}d;$ z?7p|o<2k(e%R7RqJ4hWTwB_J|kJpff%)93L`<%|VQuK$SH_YcOB<1%6zwM7u7D8W4 z5CDS!0;;->y8dymWS}BuC?y3#`BDA^0vcoj0`XA-{kU*Gu8$}9?h{vhT0>nR7o#M!`HRmjBRV|qTu;9+88;rd(q z|EuQTBmP0D@^4B8dIq*XN&l$&Z&F1&16x5Wi;pqwdH%gL{~-Re@*jj;bbqn_$5i}% zp8xjrW1@Lrx#<42Xgsi*^Q5XEAbcQVLi`HOpl2D-I%vX}gW9e##yJq5`~iZO9rv{B z0;uK1*(yA4f&-BV=1Y7?Xz0Rx1@{{<1ZrSk{E>cKx?g9FYFyB^xh08=;&z-}#IrGV zt#jUuyD{y~Rcr>E4J+%Dv$7_Brw9Gk4Ur6jsskqJ4fF4tCR)(N{$}IQu&_1`6!|3&-vT5C5X>rU?0o{s-n+YO)V+1eK6eUxeR zE6@KN32+7hpYYP!nK?&96=T|>l?RZ)Tu#24qvpsw7J}AvKmMU->VvDo)}NrZ$SE;y zN+vtN7bn;gkZiu^$Ox)k-2r4dd9*)7PXZ0cPNAtEK$KW zt(=jBk(f3a{2au?F`VW^sbewGV~sh|CkrWxCp*DocR0G$m6VZ2+nnqzShOR}kTp%N z{P3G{=1{T7+-sve3P(4L0n5>eCUZq1PR=y78+?n3UvL=&rX6C(L5x+l`haeG0!)0d zL8l4>VO~tBn5>rOW`L5CQp~7ec=_nD%gK6ezj-DLSX``;_0bGL+L_ctrq&w0DIH=_xpQt;R11JBuG+O z*U;*-EIXBY#b0K6^ttx)uJJK3!YKkdV`Kef>gh)Efn0_m%r>+$Og~e9U@p=U@Su5| zWDAOW{`m2!!E!xyZEfvnra(gJc2gX)r>AGGPzo1#bCaK(oUCWz>Ia2j-0KhXvE3Fw zoXnzYY;Eu8C{b^+^zY2z@n~H-X(90a9qxmRu_Y-+kscH#>*nF^eiS20kdw+{HuLuS z98r5fg)ph~`gD5?+#dyY9+q86#Xq{PLyyv~UQ;pxxhOSTnG-1F17qXjt|irL%lnSj zWU)og>0>8N{wXFOdPZIXVcW2U2gU;4UXtX-m{6iPXx_2tySsBrc6}qhELz0fe%!D4 z{LQ2JsLxlsQBU3$Nabg?VCAzx=_5d=%^sFUJBUj|QVUuQ=&|W$aJ~301xTA z?cR^f8+FoLxdQQ8Lag$K%dU4kOULzA2MT)nXzBFt{IasLpA-CMK+SAgZ1?d-w5KGo zs=GUUeCrIyQ+-2QC`JMT0%Z3`5=f26M%`!8PuR7IbtVvT6-RIn)9K zA8}aCf1pz;5V?z5?q>+Vx)S2~{){UTi~BZLrc7t+4bfJQ^;YgccF`jGc>#~pQIhiQV@3)HxLv1O><-C^ ziF^4nX3C|SZLE)vkHbkP2)L}t z+T{`nl)~$6PD!+T)#aJ&c7boN_u3^2Jtu3q!f+)ZePGa|=L2Dw1?Y(43}70K+fVQk zW^!^U0YO2trHWWzxZL&7^E*3*jgzD8@a$kg2dc+5CRw%nEA&WDbXu zug_dwZ|*p}o?I?2F31x-&%z@mZub`jA`v*lJek~XiEodGgf4Vjkf3i3rt`7) zJRc6y@oeeKbzYRnJ;AYv1BoW2Gk>=@92{jk?Lw7yRqU9kiWix5x^d?(?&TIcvYZaV zK=?29=Z9HdiO1kj@Yx?+jd*sCd|q~LJQz#Sr_I`oYqVHR#m48bA1e2zWDn4_p<0KY z-+UqX-2uj?a{OCe5BCbcRH-7h{ry3z*D3GQ2Za}Vxr zsXdX1A+Dm%quHcXs?4pzSgsSFZb5RHA9?0rADh-P;2L5ltc}4mfW@ipe|bJDH`9w@ zn?*i!B}{-8t=Rgo;MLW-1fC)yf%X9d5|su=t4(wbW{c7y_}s1voxy?>K4z^!LC_Hd z+-ho_44C-h-{OVyNyynu$aAcHf^tJKUG^Q}2Hqn%FQRV+T>96}4#v|)`JvW&tg)*SuY0xC1JPt% zj|)x?Xr=*2E6NcJ5{G=h+U{c{dX9tNKRYA&+viz%0+>O>-Gf>1F@RBp(U-o zUS3~eV40!N&eq-B97bXL3E_$P#RmDn9{Q*R=u|2zyp0fMF|uFs5RbxOK|w(oKU?&J zfXyVNHI3-I3Ax;+uNH0-cq3=(!ecO7t|gBo(OFF>sbDm%c+fj>5t6}S(&le^K1%`X zXaYCCTn)UwJYRNM#2xnrz(Fj&5_-MJ7H+#i6%c&k;7}u&ulKXr{WZK<=mQQtVuY#1 zj+JdE6xiW76#9Vpr*rnE_2Ei%Ur$fh5hlVdbhoSKViR3SCyH|9AzyO_4eY&{SJob$ z-`4%2NH+!O)_E22cEG3Gc1Et0%?qs0{4>Ni4n`ElFiD>AB1Ju$^!v2NFOUhhVDe}Sv{u?rGmYk1G0}0+Vi0+`2|ai zGM>hB85EOT$r}bb37RarbkXaB+&u7g__dn^Wp)`a=j?VNdNdzlXy+;Rb4aB#^?yf+`P!( z$v9{X#y#|0>od%I^@4gSGn;)Q*@S#lrB}ITAtT7!L z=csoEM7-h@V<-LLm1A{<;^R3{p&Z zosvKl|K~c3Rn}6ij#j+po-KrU3rSgT-wNtw*S{7hXNx4;@)ln2x2U=$mbdI=w5gMB zl(Q!TB*+Gc2q-mDyg-N%+AQlFpOS1%^YVosX@;XD<&F^Hhxd7sHD^jL9}+Z#edeH5fWi}S�{EO#@~Vf|;n(@f*uZUGJ`EH)Ye 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 DS#%K^ literal 0 HcmV?d00001 diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 363b0368..47a9fea5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -188,7 +188,23 @@ pub fn required_str<'a>(input: &'a Value, field: &str) -> std::result::Result<&' input .get(field) .and_then(Value::as_str) - .ok_or_else(|| ToolError::missing_field(field)) + .ok_or_else(|| { + // When the field is missing, list the fields the caller *did* + // supply so the model can spot the mismatch without a retry. + let provided: Vec<&str> = input + .as_object() + .map(|obj| obj.keys().map(|k| k.as_str()).collect()) + .unwrap_or_default(); + if provided.is_empty() { + ToolError::missing_field(field) + } else { + let hint = format!( + "missing required field '{field}'. Input provided: {}", + provided.join(", ") + ); + ToolError::invalid_input(hint) + } + }) } /// Helper to extract an optional string field from JSON input. diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index c5cead59..e31f182e 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -750,7 +750,7 @@ pub(super) fn apply_reasoning_effort( "off" | "disabled" | "none" | "false" => match provider { // OpenRouter / Novita relay the same DeepSeek V4 payload shape // as DeepSeek native; they pass through `thinking` / `reasoning_effort`. - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang => { body["thinking"] = json!({ "type": "disabled" }); } ApiProvider::NvidiaNim => { @@ -760,7 +760,7 @@ pub(super) fn apply_reasoning_effort( } }, "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider { - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang => { body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } @@ -772,7 +772,7 @@ pub(super) fn apply_reasoning_effort( } }, "xhigh" | "max" | "highest" => match provider { - ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks | ApiProvider::Sglang => { body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } diff --git a/crates/tui/src/client.rs.bak2 b/crates/tui/src/client.rs.bak2 new file mode 100644 index 00000000..c5cead59 --- /dev/null +++ b/crates/tui/src/client.rs.bak2 @@ -0,0 +1,2213 @@ +//! HTTP client for DeepSeek's OpenAI-compatible Chat Completions API. +//! +//! DeepSeek documents `/chat/completions` as the primary endpoint. A legacy +//! Responses probe remains available behind `DEEPSEEK_EXPERIMENTAL_RESPONSES_API` +//! for local compatibility experiments, but normal traffic uses chat completions. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::config::{ApiProvider, Config, RetryPolicy}; +use crate::llm_client::{ + LlmClient, LlmError, RetryConfig as LlmRetryConfig, StreamEventBox, extract_retry_after, + with_retry, +}; +use crate::logging; +use crate::models::{MessageRequest, MessageResponse, ServerToolUsage, SystemPrompt, Usage}; + +pub(super) fn to_api_tool_name(name: &str) -> String { + let mut out = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + } else if ch == '-' { + out.push_str("--"); + } else { + out.push_str("-x"); + out.push_str(&format!("{:06X}", ch as u32)); + out.push('-'); + } + } + out +} + +pub(super) fn from_api_tool_name(name: &str) -> String { + let mut out = String::new(); + let mut iter = name.chars().peekable(); + while let Some(ch) = iter.next() { + if ch != '-' { + out.push(ch); + continue; + } + if let Some('-') = iter.peek().copied() { + iter.next(); + out.push('-'); + continue; + } + if iter.peek().copied() == Some('x') { + iter.next(); + let mut hex = String::new(); + for _ in 0..6 { + if let Some(h) = iter.next() { + hex.push(h); + } else { + break; + } + } + if let Ok(code) = u32::from_str_radix(&hex, 16) + && let Some(decoded) = std::char::from_u32(code) + { + if let Some('-') = iter.peek().copied() { + iter.next(); + } + out.push(decoded); + continue; + } + out.push('-'); + out.push('x'); + out.push_str(&hex); + continue; + } + out.push('-'); + } + + // Second pass: decode bare hex escapes (e.g. `x00002E`) that the model + // may produce when it mangles the `-x00002E-` delimiter form. Only + // decode when the resulting character is one that `to_api_tool_name` + // would have encoded (not alphanumeric, not `_`, not `-`). + decode_bare_hex_escapes(&out) +} + +/// Decode bare `x[0-9A-Fa-f]{6}` sequences (optionally followed by `-`) +/// that survive the standard delimiter-based pass. This handles cases +/// where the model strips or replaces the leading `-` of `-x00002E-`. +pub(super) fn decode_bare_hex_escapes(input: &str) -> String { + use regex::Regex; + use std::sync::OnceLock; + + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(r"x([0-9A-Fa-f]{6})-?").unwrap()); + + let result = re.replace_all(input, |caps: ®ex::Captures| { + let hex = &caps[1]; + if let Ok(code) = u32::from_str_radix(hex, 16) + && let Some(decoded) = std::char::from_u32(code) + { + // Only decode characters that to_api_tool_name would have encoded + if !decoded.is_ascii_alphanumeric() && decoded != '_' && decoded != '-' { + return decoded.to_string(); + } + } + // Not a character we'd encode — leave as-is + caps[0].to_string() + }); + result.into_owned() +} + +// === Types === + +/// Model descriptor returned by the provider's `/v1/models` endpoint. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AvailableModel { + pub id: String, + pub owned_by: Option, + pub created: Option, +} + +/// Client for DeepSeek's OpenAI-compatible APIs. +#[must_use] +pub struct DeepSeekClient { + pub(super) http_client: reqwest::Client, + api_key: String, + pub(super) base_url: String, + pub(super) api_provider: ApiProvider, + retry: RetryPolicy, + default_model: String, + use_chat_completions: AtomicBool, + /// Counter of chat-completions requests since last experimental Responses API probe. + /// After RESPONSES_RECOVERY_INTERVAL requests, we retry the Responses API when + /// `DEEPSEEK_EXPERIMENTAL_RESPONSES_API` is set. + chat_fallback_counter: AtomicU32, + connection_health: Arc>, + rate_limiter: Arc>, +} + +/// After this many chat-completions requests, retry the experimental Responses +/// API to see if it has recovered. +const RESPONSES_RECOVERY_INTERVAL: u32 = 20; +const CONNECTION_FAILURE_THRESHOLD: u32 = 2; +const RECOVERY_PROBE_COOLDOWN: Duration = Duration::from_secs(15); + +const DEFAULT_CLIENT_RATE_LIMIT_RPS: f64 = 8.0; +const DEFAULT_CLIENT_RATE_LIMIT_BURST: f64 = 16.0; +const ALLOW_INSECURE_HTTP_ENV: &str = "DEEPSEEK_ALLOW_INSECURE_HTTP"; +const EXPERIMENTAL_RESPONSES_API_ENV: &str = "DEEPSEEK_EXPERIMENTAL_RESPONSES_API"; + +pub(super) const SSE_BACKPRESSURE_HIGH_WATERMARK: usize = 8 * 1024 * 1024; // 8 MB +pub(super) const SSE_BACKPRESSURE_SLEEP_MS: u64 = 10; +pub(super) const SSE_MAX_LINES_PER_CHUNK: usize = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectionState { + Healthy, + Degraded, + Recovering, +} + +#[derive(Debug)] +struct ConnectionHealth { + state: ConnectionState, + consecutive_failures: u32, + last_failure: Option, + last_success: Option, + last_probe: Option, +} + +impl Default for ConnectionHealth { + fn default() -> Self { + Self { + state: ConnectionState::Healthy, + consecutive_failures: 0, + last_failure: None, + last_success: None, + last_probe: None, + } + } +} + +#[derive(Debug)] +struct TokenBucket { + enabled: bool, + capacity: f64, + tokens: f64, + refill_per_sec: f64, + last_refill: Instant, +} + +impl TokenBucket { + fn from_env() -> Self { + let rps = std::env::var("DEEPSEEK_RATE_LIMIT_RPS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_CLIENT_RATE_LIMIT_RPS) + .max(0.0); + let burst = std::env::var("DEEPSEEK_RATE_LIMIT_BURST") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_CLIENT_RATE_LIMIT_BURST) + .max(1.0); + let enabled = rps > 0.0; + Self { + enabled, + capacity: burst, + tokens: burst, + refill_per_sec: rps, + last_refill: Instant::now(), + } + } + + fn refill(&mut self, now: Instant) { + if !self.enabled { + return; + } + let elapsed = now.duration_since(self.last_refill).as_secs_f64(); + self.last_refill = now; + self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity); + } + + fn delay_until_available(&mut self, tokens: f64) -> Option { + if !self.enabled { + return None; + } + let now = Instant::now(); + self.refill(now); + if self.tokens >= tokens { + self.tokens -= tokens; + return None; + } + let needed = tokens - self.tokens; + self.tokens = 0.0; + if self.refill_per_sec <= 0.0 { + return Some(Duration::from_secs(1)); + } + Some(Duration::from_secs_f64(needed / self.refill_per_sec)) + } +} + +fn apply_request_success(health: &mut ConnectionHealth, now: Instant) -> bool { + let recovered = health.state != ConnectionState::Healthy; + health.state = ConnectionState::Healthy; + health.consecutive_failures = 0; + health.last_success = Some(now); + recovered +} + +fn apply_request_failure(health: &mut ConnectionHealth, now: Instant) { + health.consecutive_failures = health.consecutive_failures.saturating_add(1); + health.last_failure = Some(now); + if health.consecutive_failures >= CONNECTION_FAILURE_THRESHOLD { + health.state = ConnectionState::Degraded; + } +} + +fn mark_recovery_probe_if_due(health: &mut ConnectionHealth, now: Instant) -> bool { + if health.state == ConnectionState::Healthy { + return false; + } + if health + .last_probe + .is_some_and(|last| now.duration_since(last) < RECOVERY_PROBE_COOLDOWN) + { + return false; + } + health.last_probe = Some(now); + health.state = ConnectionState::Recovering; + true +} + +fn buffer_pool() -> &'static StdMutex>> { + static POOL: OnceLock>>> = OnceLock::new(); + POOL.get_or_init(|| StdMutex::new(Vec::new())) +} + +fn acquire_stream_buffer() -> Vec { + if let Ok(mut pool) = buffer_pool().lock() { + pool.pop().unwrap_or_else(|| Vec::with_capacity(8192)) + } else { + Vec::with_capacity(8192) + } +} + +fn release_stream_buffer(mut buf: Vec) { + buf.clear(); + if buf.capacity() > 256 * 1024 { + buf.shrink_to(256 * 1024); + } + if let Ok(mut pool) = buffer_pool().lock() + && pool.len() < 8 + { + pool.push(buf); + } +} + +impl Clone for DeepSeekClient { + fn clone(&self) -> Self { + Self { + http_client: self.http_client.clone(), + api_key: self.api_key.clone(), + base_url: self.base_url.clone(), + api_provider: self.api_provider, + retry: self.retry.clone(), + default_model: self.default_model.clone(), + use_chat_completions: AtomicBool::new( + self.use_chat_completions.load(Ordering::Relaxed), + ), + chat_fallback_counter: AtomicU32::new( + self.chat_fallback_counter.load(Ordering::Relaxed), + ), + connection_health: self.connection_health.clone(), + rate_limiter: self.rate_limiter.clone(), + } + } +} + +// === Helpers === + +/// Maximum bytes to read from an error response body (64 KB). +pub(super) const ERROR_BODY_MAX_BYTES: usize = 64 * 1024; + +/// Read an error response body with a size limit to prevent unbounded allocation. +pub(super) async fn bounded_error_text(response: reqwest::Response, max_bytes: usize) -> String { + use futures_util::StreamExt; + let mut stream = response.bytes_stream(); + let mut buf = Vec::with_capacity(max_bytes.min(8192)); + while let Some(chunk) = stream.next().await { + let Ok(chunk) = chunk else { break }; + let remaining = max_bytes.saturating_sub(buf.len()); + if remaining == 0 { + break; + } + buf.extend_from_slice(&chunk[..chunk.len().min(remaining)]); + } + String::from_utf8_lossy(&buf).into_owned() +} + +fn validate_base_url_security(base_url: &str) -> Result<()> { + if base_url.starts_with("https://") + || base_url.starts_with("http://localhost") + || base_url.starts_with("http://127.0.0.1") + || base_url.starts_with("http://[::1]") + { + return Ok(()); + } + + if base_url.starts_with("http://") + && std::env::var(ALLOW_INSECURE_HTTP_ENV) + .ok() + .as_deref() + .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) + { + logging::warn(format!( + "Using insecure HTTP base URL because {} is set", + ALLOW_INSECURE_HTTP_ENV + )); + return Ok(()); + } + + if base_url.starts_with("http://") { + anyhow::bail!( + "Refusing insecure base URL '{}'. Use HTTPS or set {}=1 to override for trusted environments.", + base_url, + ALLOW_INSECURE_HTTP_ENV + ); + } + + anyhow::bail!( + "Refusing base URL '{}': only HTTPS (or explicitly allowed HTTP) URLs are supported.", + base_url, + ) +} + +fn experimental_responses_api_enabled() -> bool { + std::env::var(EXPERIMENTAL_RESPONSES_API_ENV) + .ok() + .as_deref() + .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) +} + +pub(super) fn versioned_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + if trimmed.ends_with("/v1") || trimmed.ends_with("/beta") { + trimmed.to_string() + } else { + format!("{trimmed}/v1") + } +} + +pub(super) fn api_url(base_url: &str, path: &str) -> String { + format!( + "{}/{}", + versioned_base_url(base_url).trim_end_matches('/'), + path.trim_start_matches('/') + ) +} + +// === DeepSeekClient === + +/// Returns true when DEEPSEEK_FORCE_HTTP1 is set to a truthy value +/// (`1`, `true`, `yes`, `on`, case-insensitive). Used by `build_http_client` +/// to opt out of HTTP/2 entirely when DeepSeek's edge mishandles long-lived H2 +/// streams (#103). Anything else (unset, `0`, `false`, ...) leaves HTTP/2 on. +fn force_http1_from_env() -> bool { + std::env::var("DEEPSEEK_FORCE_HTTP1") + .ok() + .map(|v| v.trim().to_ascii_lowercase()) + .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on")) +} + +impl DeepSeekClient { + /// Create a DeepSeek client from CLI configuration. + pub fn new(config: &Config) -> Result { + let api_key = config.deepseek_api_key()?; + let base_url = config.deepseek_base_url(); + let api_provider = config.api_provider(); + validate_base_url_security(&base_url)?; + let retry = config.retry_policy(); + let default_model = config.default_model(); + + logging::info(format!("API provider: {}", api_provider.as_str())); + logging::info(format!("API base URL: {base_url}")); + logging::info(format!( + "Retry policy: enabled={}, max_retries={}, initial_delay={}s, max_delay={}s", + retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay + )); + + let http_client = Self::build_http_client(&api_key)?; + + Ok(Self { + http_client, + api_key, + base_url, + api_provider, + retry, + default_model, + use_chat_completions: AtomicBool::new(false), + chat_fallback_counter: AtomicU32::new(0), + connection_health: Arc::new(AsyncMutex::new(ConnectionHealth::default())), + rate_limiter: Arc::new(AsyncMutex::new(TokenBucket::from_env())), + }) + } + + fn build_http_client(api_key: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {api_key}"))?, + ); + let mut builder = reqwest::Client::builder() + .default_headers(headers) + .connect_timeout(Duration::from_secs(30)) + // The blanket 300s request timeout was incompatible with V4-pro + // thinking turns that legitimately exceed that wall-clock window + // (see #103). Drop it; per-chunk and per-stream guards in + // engine.rs already bound how long we'll wait without progress. + .tcp_keepalive(Some(Duration::from_secs(30))) + .http2_keep_alive_interval(Some(Duration::from_secs(15))) + .http2_keep_alive_timeout(Duration::from_secs(20)) + .min_tls_version(reqwest::tls::Version::TLS_1_2); + // Escape hatch (#103): some DeepSeek edge nodes mishandle long-lived + // HTTP/2 streams. Setting DEEPSEEK_FORCE_HTTP1=1 pins the client to + // HTTP/1.1 so users can experiment without us committing to that + // path as the default. + if force_http1_from_env() { + logging::info("DEEPSEEK_FORCE_HTTP1=1 — pinning HTTP client to HTTP/1.1"); + builder = builder.http1_only(); + } + builder.build().map_err(Into::into) + } + + /// List available models from the provider. + pub async fn list_models(&self) -> Result> { + let url = api_url(&self.base_url, "models"); + let response = self.send_with_retry(|| self.http_client.get(&url)).await?; + + let status = response.status(); + if !status.is_success() { + let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; + anyhow::bail!("Failed to list models: HTTP {status}: {error_text}"); + } + let response_text = response.text().await.unwrap_or_default(); + + parse_models_response(&response_text) + } + + async fn wait_for_rate_limit(&self) { + let maybe_delay = { + let mut limiter = self.rate_limiter.lock().await; + limiter.delay_until_available(1.0) + }; + if let Some(delay) = maybe_delay { + tokio::time::sleep(delay).await; + } + } + + async fn mark_request_success(&self) { + let mut health = self.connection_health.lock().await; + if apply_request_success(&mut health, Instant::now()) { + logging::info("Connection recovered"); + } + } + + async fn mark_request_failure(&self, reason: &str) { + let mut health = self.connection_health.lock().await; + apply_request_failure(&mut health, Instant::now()); + logging::warn(format!( + "Connection degraded (failures={}): {}", + health.consecutive_failures, reason + )); + } + + async fn maybe_probe_recovery(&self) { + let should_probe = { + let mut health = self.connection_health.lock().await; + mark_recovery_probe_if_due(&mut health, Instant::now()) + }; + if !should_probe { + return; + } + let health_url = api_url(&self.base_url, "models"); + let probe = self.http_client.get(health_url).send().await; + match probe { + Ok(resp) if resp.status().is_success() => { + self.mark_request_success().await; + logging::info("Recovery probe succeeded"); + } + Ok(resp) => { + self.mark_request_failure(&format!("probe status={}", resp.status())) + .await; + } + Err(err) => { + self.mark_request_failure(&format!("probe error={err}")) + .await; + } + } + } + + pub(super) async fn send_with_retry(&self, mut build: F) -> Result + where + F: FnMut() -> reqwest::RequestBuilder, + { + let retry_cfg: LlmRetryConfig = self.retry.clone().into(); + let request_result = with_retry( + &retry_cfg, + || { + let request = build(); + async move { + self.wait_for_rate_limit().await; + let response = request + .send() + .await + .map_err(|err| LlmError::from_reqwest(&err))?; + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let retryable = status.as_u16() == 429 || status.is_server_error(); + if !retryable { + return Ok(response); + } + let retry_after = extract_retry_after(response.headers()); + let body = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; + Err(LlmError::from_http_response_with_retry_after( + status.as_u16(), + &body, + retry_after, + )) + } + }, + Some(Box::new(|err, attempt, delay| { + logging::warn(format!( + "HTTP retry reason={} attempt={} delay={:.2}s", + match err { + LlmError::RateLimited { .. } => "rate_limited", + LlmError::ServerError { .. } => "server_error", + LlmError::NetworkError(_) => "network_error", + LlmError::Timeout(_) => "timeout", + _ => "other", + }, + attempt + 1, + delay.as_secs_f64(), + )); + })), + ) + .await; + + match request_result { + Ok(response) => { + self.mark_request_success().await; + Ok(response) + } + Err(err) => { + self.mark_request_failure(&err.to_string()).await; + self.maybe_probe_recovery().await; + Err(anyhow::anyhow!(err.to_string())) + } + } + } +} + +impl LlmClient for DeepSeekClient { + fn provider_name(&self) -> &'static str { + self.api_provider.as_str() + } + + fn model(&self) -> &str { + &self.default_model + } + + async fn health_check(&self) -> Result { + let health_url = api_url(&self.base_url, "models"); + self.wait_for_rate_limit().await; + let response = self.http_client.get(health_url).send().await; + match response { + Ok(resp) if resp.status().is_success() => { + self.mark_request_success().await; + Ok(true) + } + Ok(resp) => { + self.mark_request_failure(&format!("health status={}", resp.status())) + .await; + Ok(false) + } + Err(err) => { + self.mark_request_failure(&format!("health error={err}")) + .await; + Ok(false) + } + } + } + + async fn create_message(&self, request: MessageRequest) -> Result { + if !experimental_responses_api_enabled() { + return self.create_message_chat(&request).await; + } + + // Check if it's time to probe Responses API recovery + if self.use_chat_completions.load(Ordering::Relaxed) { + let count = self.chat_fallback_counter.fetch_add(1, Ordering::Relaxed); + if count > 0 && count.is_multiple_of(RESPONSES_RECOVERY_INTERVAL) { + logging::info("Probing Responses API recovery..."); + let request_clone = request.clone(); + match self.create_message_responses(&request).await? { + Ok(message) => { + logging::info("Responses API recovered! Switching back."); + self.use_chat_completions.store(false, Ordering::Relaxed); + self.chat_fallback_counter.store(0, Ordering::Relaxed); + return Ok(message); + } + Err(_) => { + logging::info("Responses API still unavailable, continuing with chat."); + } + } + return self.create_message_chat(&request_clone).await; + } + return self.create_message_chat(&request).await; + } + + let request_clone = request.clone(); + match self.create_message_responses(&request).await? { + Ok(message) => Ok(message), + Err(fallback) => { + logging::warn(format!( + "Responses API unavailable (HTTP {}). Falling back to chat completions.", + fallback.status + )); + logging::info(format!( + "Responses fallback body: {}", + crate::utils::truncate_with_ellipsis(&fallback.body, 500, "...") + )); + self.use_chat_completions.store(true, Ordering::Relaxed); + self.chat_fallback_counter.store(0, Ordering::Relaxed); + self.create_message_chat(&request_clone).await + } + } + } + + async fn create_message_stream(&self, request: MessageRequest) -> Result { + self.handle_chat_completion_stream(request).await + } +} + +#[derive(Debug, Deserialize)] +struct ModelsListResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct ModelListItem { + id: String, + #[serde(default)] + owned_by: Option, + #[serde(default)] + created: Option, +} + +pub(super) fn parse_models_response(payload: &str) -> Result> { + let parsed: ModelsListResponse = + serde_json::from_str(payload).context("Failed to parse model list JSON")?; + + let mut models = parsed + .data + .into_iter() + .map(|item| AvailableModel { + id: item.id, + owned_by: item.owned_by, + created: item.created, + }) + .collect::>(); + models.sort_by(|a, b| a.id.cmp(&b.id)); + models.dedup_by(|a, b| a.id == b.id); + Ok(models) +} + +pub(super) fn system_to_instructions(system: Option) -> Option { + match system { + Some(SystemPrompt::Text(text)) => Some(text), + Some(SystemPrompt::Blocks(blocks)) => { + let joined = blocks + .into_iter() + .map(|b| b.text) + .collect::>() + .join("\n\n---\n\n"); + if joined.trim().is_empty() { + None + } else { + Some(joined) + } + } + None => None, + } +} + +pub(super) fn apply_reasoning_effort( + body: &mut Value, + effort: Option<&str>, + provider: ApiProvider, +) { + let Some(effort) = effort else { + return; + }; + let normalized = effort.trim().to_ascii_lowercase(); + match normalized.as_str() { + "off" | "disabled" | "none" | "false" => match provider { + // OpenRouter / Novita relay the same DeepSeek V4 payload shape + // as DeepSeek native; they pass through `thinking` / `reasoning_effort`. + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { + body["thinking"] = json!({ "type": "disabled" }); + } + ApiProvider::NvidiaNim => { + body["chat_template_kwargs"] = json!({ + "thinking": false, + }); + } + }, + "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider { + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { + body["reasoning_effort"] = json!("high"); + body["thinking"] = json!({ "type": "enabled" }); + } + ApiProvider::NvidiaNim => { + body["chat_template_kwargs"] = json!({ + "thinking": true, + "reasoning_effort": "high", + }); + } + }, + "xhigh" | "max" | "highest" => match provider { + ApiProvider::Deepseek | ApiProvider::Openrouter | ApiProvider::Novita => { + body["reasoning_effort"] = json!("max"); + body["thinking"] = json!({ "type": "enabled" }); + } + ApiProvider::NvidiaNim => { + body["chat_template_kwargs"] = json!({ + "thinking": true, + "reasoning_effort": "max", + }); + } + }, + _ => { + // Unknown value — do not mutate the request, let the provider + // apply its own defaults. + } + } +} + +pub(super) fn parse_usage(usage: Option<&Value>) -> Usage { + let input_tokens = usage + .and_then(|u| u.get("input_tokens").or_else(|| u.get("prompt_tokens"))) + .and_then(Value::as_u64) + .unwrap_or(0); + let output_tokens = usage + .and_then(|u| { + u.get("output_tokens") + .or_else(|| u.get("completion_tokens")) + }) + .and_then(Value::as_u64) + .unwrap_or(0); + let prompt_cache_hit_tokens = usage + .and_then(|u| u.get("prompt_cache_hit_tokens")) + .and_then(Value::as_u64) + .map(|v| v as u32); + let prompt_cache_miss_tokens = usage + .and_then(|u| u.get("prompt_cache_miss_tokens")) + .and_then(Value::as_u64) + .map(|v| v as u32); + let reasoning_tokens = usage + .and_then(|u| u.get("completion_tokens_details")) + .and_then(|details| details.get("reasoning_tokens")) + .and_then(Value::as_u64) + .map(|v| v as u32); + + let server_tool_use = usage.and_then(|u| u.get("server_tool_use")).map(|server| { + let code_execution_requests = server + .get("code_execution_requests") + .and_then(Value::as_u64) + .map(|v| v as u32); + let tool_search_requests = server + .get("tool_search_requests") + .and_then(Value::as_u64) + .map(|v| v as u32); + ServerToolUsage { + code_execution_requests, + tool_search_requests, + } + }); + + Usage { + input_tokens: input_tokens as u32, + output_tokens: output_tokens as u32, + prompt_cache_hit_tokens, + prompt_cache_miss_tokens, + reasoning_tokens, + reasoning_replay_tokens: None, + server_tool_use, + } +} + +mod chat; +mod responses; + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::chat::{ + build_chat_messages, build_chat_messages_for_request, count_reasoning_replay_chars, + parse_chat_message, parse_sse_chunk, sanitize_thinking_mode_messages, tool_to_chat, + }; + use crate::models::{ContentBlock, ContentBlockStart, Delta, Message, StreamEvent, Tool}; + use serde_json::json; + + #[test] + fn tool_name_roundtrip_dot() { + let original = "multi_tool_use.parallel"; + let encoded = to_api_tool_name(original); + assert_eq!(encoded, "multi_tool_use-x00002E-parallel"); + let decoded = from_api_tool_name(&encoded); + assert_eq!(decoded, original); + } + + #[test] + fn tool_name_decode_mangled_dot_prefix() { + // Model replaces leading `-` with `.` in `-x00002E-` + let mangled = "multi_tool_use.x00002E-parallel"; + let decoded = from_api_tool_name(mangled); + assert_eq!(decoded, "multi_tool_use..parallel"); + } + + #[test] + fn tool_name_decode_bare_hex_no_trailing_dash() { + // Bare hex without trailing dash + let mangled = "foo_x00002Ebar"; + let decoded = from_api_tool_name(mangled); + assert_eq!(decoded, "foo_.bar"); + } + + #[test] + fn tool_name_bare_hex_preserves_alnum() { + // x000041 = 'A' — should NOT be decoded (alphanumeric) + let input = "foox000041bar"; + let decoded = from_api_tool_name(input); + assert_eq!(decoded, input); + } + + #[test] + fn tool_name_bare_hex_preserves_underscore() { + // x00005F = '_' — should NOT be decoded + let input = "foox00005Fbar"; + let decoded = from_api_tool_name(input); + assert_eq!(decoded, input); + } + + #[test] + fn tool_name_roundtrip_colon() { + let original = "mcp__server:tool_name"; + let encoded = to_api_tool_name(original); + let decoded = from_api_tool_name(&encoded); + assert_eq!(decoded, original); + } + + #[test] + fn api_url_handles_default_v1_and_beta_base_urls() { + assert_eq!( + api_url("https://api.deepseek.com", "chat/completions"), + "https://api.deepseek.com/v1/chat/completions" + ); + assert_eq!( + api_url("https://api.deepseek.com/v1", "chat/completions"), + "https://api.deepseek.com/v1/chat/completions" + ); + assert_eq!( + api_url("https://api.deepseek.com/beta", "chat/completions"), + "https://api.deepseek.com/beta/chat/completions" + ); + } + + #[test] + fn chat_messages_keep_reasoning_content_on_all_assistant_messages() { + let message = Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "plan".to_string(), + }, + ContentBlock::Text { + text: "done".to_string(), + cache_control: None, + }, + ], + }; + let out = build_chat_messages(None, &[message], "deepseek-v4-pro"); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("assistant message"); + assert_eq!( + assistant.get("content").and_then(Value::as_str), + Some("done") + ); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("plan"), + "thinking-mode models must keep reasoning_content on ALL assistant messages" + ); + } + + #[test] + fn chat_messages_keep_thinking_only_assistant_for_v4_flash() { + let message = Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Thinking { + thinking: "plan".to_string(), + }], + }; + let out = build_chat_messages(None, &[message], "deepseek-v4-flash"); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("thinking-only assistant kept for V4 model"); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("plan") + ); + } + + #[test] + fn chat_messages_keep_thinking_only_assistant_for_v4_pro() { + let message = Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Thinking { + thinking: "plan".to_string(), + }], + }; + let out = build_chat_messages(None, &[message], "deepseek-v4-pro"); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("thinking-only assistant kept for V4 model"); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("plan") + ); + } + + #[test] + fn chat_messages_keep_thinking_only_assistant_for_r_series_model() { + let message = Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Thinking { + thinking: "plan".to_string(), + }], + }; + let out = build_chat_messages(None, &[message], "deepseek-r2-lite-preview"); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("thinking-only assistant kept for R-series model"); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("plan") + ); + } + + #[test] + fn chat_messages_preserve_current_tool_round_reasoning_for_reasoner_model() { + let messages = vec![ + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Need the date".to_string(), + cache_control: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "Need to call a tool".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "get_date".to_string(), + input: json!({}), + caller: None, + }, + ], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "2026-04-23".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("assistant message"); + assert_eq!(assistant.get("content").and_then(Value::as_str), Some("")); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("Need to call a tool") + ); + } + + #[test] + fn chat_messages_replay_prior_tool_round_reasoning_after_new_user_turn() { + let messages = vec![ + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Need the date".to_string(), + cache_control: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "Need to call a tool".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "get_date".to_string(), + input: json!({}), + caller: None, + }, + ], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "2026-04-23".to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "It is 2026-04-23.".to_string(), + cache_control: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Thanks. Next question.".to_string(), + cache_control: None, + }], + }, + ]; + let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); + let tool_assistant = out + .iter() + .find(|value| { + value.get("role").and_then(Value::as_str) == Some("assistant") + && value.get("tool_calls").is_some() + }) + .expect("tool-call assistant message"); + assert_eq!( + tool_assistant + .get("reasoning_content") + .and_then(Value::as_str), + Some("Need to call a tool"), + "DeepSeek thinking mode requires reasoning_content to be replayed for tool-call rounds across all subsequent user turns" + ); + } + + #[test] + fn chat_messages_replay_completed_tool_round_reasoning_after_final_answer() { + let messages = vec![ + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Need the date".to_string(), + cache_control: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "Need to call a tool".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "get_date".to_string(), + input: json!({}), + caller: None, + }, + ], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "2026-04-23".to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "It is 2026-04-23.".to_string(), + cache_control: None, + }], + }, + ]; + let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); + let tool_assistant = out + .iter() + .find(|value| { + value.get("role").and_then(Value::as_str) == Some("assistant") + && value.get("tool_calls").is_some() + }) + .expect("tool-call assistant message"); + assert_eq!( + tool_assistant + .get("reasoning_content") + .and_then(Value::as_str), + Some("Need to call a tool") + ); + let final_assistant = out + .iter() + .rfind(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("final assistant message"); + assert!( + final_assistant + .get("reasoning_content") + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()), + "all assistant messages must carry reasoning_content in thinking mode" + ); + } + + #[test] + fn chat_messages_replay_v4_tool_round_reasoning_after_new_user_turn() { + let messages = vec![ + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Use a tool".to_string(), + cache_control: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "Need a tool for this".to_string(), + }, + ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "read_file".to_string(), + input: json!({"path": "Cargo.toml"}), + caller: None, + }, + ], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: "workspace manifest".to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Read it.".to_string(), + cache_control: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Now continue.".to_string(), + cache_control: None, + }], + }, + ]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); + let tool_assistant = out + .iter() + .find(|value| { + value.get("role").and_then(Value::as_str) == Some("assistant") + && value.get("tool_calls").is_some() + }) + .expect("tool-call assistant message"); + assert_eq!( + tool_assistant + .get("reasoning_content") + .and_then(Value::as_str), + Some("Need a tool for this") + ); + } + + #[test] + fn chat_messages_substitute_placeholder_when_v4_tool_round_missing_reasoning() { + let messages = vec![ + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Use a tool".to_string(), + cache_control: None, + }], + }, + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "call-without-reasoning".to_string(), + name: "read_file".to_string(), + input: json!({"path": "Cargo.toml"}), + caller: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-without-reasoning".to_string(), + content: "workspace manifest".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-pro"); + + let assistant = out + .iter() + .find(|value| { + value.get("role").and_then(Value::as_str) == Some("assistant") + && value.get("tool_calls").is_some() + }) + .expect("tool-call assistant message should be retained with placeholder"); + assert!( + assistant + .get("reasoning_content") + .and_then(Value::as_str) + .is_some_and(|value| !value.trim().is_empty()), + "missing reasoning_content should be substituted with a non-empty placeholder so the API accepts the request" + ); + assert!( + out.iter() + .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), + "matching tool_result must remain so the conversation chain stays intact" + ); + } + + #[test] + fn chat_messages_allow_tool_round_without_reasoning_when_thinking_disabled() { + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "call-no-thinking".to_string(), + name: "read_file".to_string(), + input: json!({"path": "Cargo.toml"}), + caller: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-no-thinking".to_string(), + content: "workspace manifest".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ], + max_tokens: 1024, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("off".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let out = build_chat_messages_for_request(&request); + assert!( + out.iter().any( + |value| value.get("role").and_then(Value::as_str) == Some("assistant") + && value.get("tool_calls").is_some() + ), + "tool calls remain valid when thinking mode is disabled" + ); + assert!( + out.iter() + .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), + "matching tool result should remain" + ); + } + + #[test] + fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() { + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some("max"), ApiProvider::Deepseek); + + assert_eq!( + body.get("reasoning_effort").and_then(Value::as_str), + Some("max") + ); + assert_eq!( + body.pointer("/thinking/type").and_then(Value::as_str), + Some("enabled") + ); + assert!(body.get("extra_body").is_none()); + } + + #[test] + fn reasoning_effort_off_disables_top_level_thinking() { + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some("off"), ApiProvider::Deepseek); + + assert_eq!( + body.pointer("/thinking/type").and_then(Value::as_str), + Some("disabled") + ); + assert!(body.get("reasoning_effort").is_none()); + assert!(body.get("extra_body").is_none()); + } + + #[test] + fn reasoning_effort_uses_nvidia_nim_chat_template_kwargs() { + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some("max"), ApiProvider::NvidiaNim); + + assert_eq!( + body.pointer("/chat_template_kwargs/thinking") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + body.pointer("/chat_template_kwargs/reasoning_effort") + .and_then(Value::as_str), + Some("max") + ); + assert!(body.get("thinking").is_none()); + assert!(body.get("reasoning_effort").is_none()); + } + + #[test] + fn reasoning_effort_off_disables_nvidia_nim_thinking() { + let mut body = json!({}); + apply_reasoning_effort(&mut body, Some("off"), ApiProvider::NvidiaNim); + + assert_eq!( + body.pointer("/chat_template_kwargs/thinking") + .and_then(Value::as_bool), + Some(false) + ); + assert!( + body.pointer("/chat_template_kwargs/reasoning_effort") + .is_none() + ); + } + + #[test] + fn chat_parser_accepts_nvidia_nim_reasoning_field() -> Result<()> { + let response = parse_chat_message(&json!({ + "id": "chatcmpl-test", + "model": "deepseek-ai/deepseek-v4-pro", + "choices": [{ + "message": { + "role": "assistant", + "reasoning": "thinking via NIM", + "content": "final answer" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 3 + } + }))?; + + assert!(matches!( + response.content.first(), + Some(ContentBlock::Thinking { thinking }) if thinking == "thinking via NIM" + )); + assert!(matches!( + response.content.get(1), + Some(ContentBlock::Text { text, .. }) if text == "final answer" + )); + Ok(()) + } + + #[test] + fn sse_parser_accepts_nvidia_nim_reasoning_delta() { + let mut content_index = 0; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices = std::collections::HashMap::new(); + let events = parse_sse_chunk( + &json!({ + "choices": [{ + "delta": { + "reasoning": "nim thought" + } + }] + }), + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + true, + ); + + assert!(events.iter().any(|event| matches!( + event, + StreamEvent::ContentBlockDelta { + delta: Delta::ThinkingDelta { thinking }, + .. + } if thinking == "nim thought" + ))); + } + + #[test] + fn chat_tool_strict_flag_is_nested_under_function() { + let tool = Tool { + tool_type: Some("function".to_string()), + name: "emit_json".to_string(), + description: "Emit JSON".to_string(), + input_schema: json!({"type": "object", "properties": {}}), + allowed_callers: None, + defer_loading: None, + input_examples: None, + strict: Some(true), + cache_control: None, + }; + let encoded = tool_to_chat(&tool); + assert_eq!( + encoded + .get("function") + .and_then(|function| function.get("strict")) + .and_then(Value::as_bool), + Some(true) + ); + assert!(encoded.get("strict").is_none()); + } + + #[test] + fn chat_messages_drop_thinking_only_assistant_for_non_reasoning_model() { + let message = Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Thinking { + thinking: "plan".to_string(), + }], + }; + let out = build_chat_messages(None, &[message], "some-non-deepseek-model"); + assert!( + !out.iter() + .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")), + "non-reasoning model should drop thinking-only assistant" + ); + } + + #[test] + fn parse_sse_chunk_closes_each_tool_block_with_matching_index() { + let chunk = json!({ + "choices": [{ + "delta": { + "tool_calls": [ + { + "index": 0, + "id": "call_0", + "function": {"name": "read_file", "arguments": "{\"path\":\"a\"}"} + }, + { + "index": 1, + "id": "call_1", + "function": {"name": "read_file", "arguments": "{\"path\":\"b\"}"} + } + ] + }, + "finish_reason": "tool_calls" + }] + }); + + let mut content_index = 0; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices: std::collections::HashMap = + std::collections::HashMap::new(); + let events = parse_sse_chunk( + &chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + false, + ); + + let starts: Vec = events + .iter() + .filter_map(|event| match event { + StreamEvent::ContentBlockStart { + index, + content_block: ContentBlockStart::ToolUse { .. }, + } => Some(*index), + _ => None, + }) + .collect(); + let stops: Vec = events + .iter() + .filter_map(|event| match event { + StreamEvent::ContentBlockStop { index } => Some(*index), + _ => None, + }) + .collect(); + let deltas: Vec = events + .iter() + .filter_map(|event| match event { + StreamEvent::ContentBlockDelta { + index, + delta: Delta::InputJsonDelta { .. }, + } => Some(*index), + _ => None, + }) + .collect(); + + assert_eq!(starts, vec![0, 1]); + assert_eq!(stops, vec![0, 1]); + assert_eq!(deltas, vec![0, 1]); + } + + #[test] + fn parse_sse_chunk_handles_empty_choices_usage_chunk() { + let chunk = json!({ + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "prompt_cache_hit_tokens": 70, + "prompt_cache_miss_tokens": 30 + } + }); + + let mut content_index = 0; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices: std::collections::HashMap = + std::collections::HashMap::new(); + let events = parse_sse_chunk( + &chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + false, + ); + + let StreamEvent::MessageDelta { + usage: Some(usage), .. + } = &events[0] + else { + panic!("expected usage delta"); + }; + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.prompt_cache_hit_tokens, Some(70)); + assert_eq!(usage.prompt_cache_miss_tokens, Some(30)); + } + + #[test] + fn chat_messages_drop_orphan_tool_results() { + let messages = vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "ok".to_string(), + is_error: None, + content_blocks: None, + }], + }]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); + assert!( + !out.iter() + .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") }) + ); + } + + #[test] + fn chat_messages_include_tool_results_when_call_present() { + let messages = vec![ + Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "Need to inspect the directory".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "list_dir".to_string(), + input: json!({}), + caller: None, + }, + ], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "ok".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); + assert!( + out.iter() + .any(|value| { value.get("role").and_then(Value::as_str) == Some("tool") }) + ); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("assistant message"); + assert!(assistant.get("tool_calls").is_some()); + } + + #[test] + fn chat_messages_encode_tool_call_names() { + let messages = vec![ + Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "Need to search".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-1".to_string(), + name: "web.run".to_string(), + input: json!({}), + caller: None, + }, + ], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "ok".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("assistant message"); + let tool_calls = assistant + .get("tool_calls") + .and_then(Value::as_array) + .expect("tool_calls array"); + let function_name = tool_calls + .first() + .and_then(|call| call.get("function")) + .and_then(|func| func.get("name")) + .and_then(Value::as_str) + .expect("tool call function name"); + + assert_eq!(function_name, to_api_tool_name("web.run")); + } + + #[test] + fn chat_messages_strips_orphaned_tool_calls_after_compaction() { + // Simulates post-compaction state: assistant has tool_calls but the + // tool result messages were summarized away. + let messages = vec![ + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::ToolUse { + id: "tool-orphan".to_string(), + name: "read_file".to_string(), + input: json!({"path": "src/main.rs"}), + caller: None, + }], + }, + // No tool result follows — it was removed by compaction. + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "continue".to_string(), + cache_control: None, + }], + }, + ]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")); + // The safety net may drop the assistant message entirely if it only + // contained orphaned tool_calls and no text content. + assert!( + assistant.is_none(), + "assistant without content/tool_calls should be removed" + ); + assert!( + !out.iter() + .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")), + "orphaned tool results should also be removed" + ); + } + + #[test] + fn chat_messages_keeps_valid_tool_calls_intact() { + // Complete call+result pair should NOT be stripped. + let messages = vec![ + Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::Thinking { + thinking: "Need to list files".to_string(), + }, + ContentBlock::ToolUse { + id: "tool-ok".to_string(), + name: "list_dir".to_string(), + input: json!({}), + caller: None, + }, + ], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-ok".to_string(), + content: "files".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("assistant message"); + assert!( + assistant.get("tool_calls").is_some(), + "valid tool_calls should remain intact" + ); + assert!( + out.iter() + .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), + "tool result should remain" + ); + } + + #[test] + fn chat_messages_strips_partial_tool_results() { + let messages = vec![ + Message { + role: "assistant".to_string(), + content: vec![ + ContentBlock::ToolUse { + id: "t1".to_string(), + name: "read_file".to_string(), + input: json!({"path": "a.rs"}), + caller: None, + }, + ContentBlock::ToolUse { + id: "t2".to_string(), + name: "read_file".to_string(), + input: json!({"path": "b.rs"}), + caller: None, + }, + ContentBlock::ToolUse { + id: "t3".to_string(), + name: "shell".to_string(), + input: json!({"cmd": "ls"}), + caller: None, + }, + ], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "t1".to_string(), + content: "content a".to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: "t2".to_string(), + content: "content b".to_string(), + is_error: None, + content_blocks: None, + }], + }, + // No result for t3 + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "continue".to_string(), + cache_control: None, + }], + }, + ]; + + let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); + let assistant = out + .iter() + .find(|v| v.get("role").and_then(Value::as_str) == Some("assistant")); + assert!( + assistant.is_none(), + "assistant with only partial tool_calls should be removed" + ); + assert!( + !out.iter() + .any(|v| v.get("role").and_then(Value::as_str) == Some("tool")), + "all orphaned tool results should be removed" + ); + } + + #[test] + fn parse_models_response_parses_and_deduplicates() { + let payload = r#"{ + "object": "list", + "data": [ + {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1}, + {"id": "deepseek-v4-flash", "object": "model"}, + {"id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek", "created": 1} + ] + }"#; + + let models = parse_models_response(payload).expect("parse models"); + assert_eq!( + models, + vec![ + AvailableModel { + id: "deepseek-v4-flash".to_string(), + owned_by: None, + created: None + }, + AvailableModel { + id: "deepseek-v4-pro".to_string(), + owned_by: Some("deepseek".to_string()), + created: Some(1) + } + ] + ); + } + + #[test] + fn parse_usage_reads_deepseek_cache_and_reasoning_tokens() { + fn parse_usage(usage: Option<&Value>) -> Usage { + let usage = usage.expect("usage"); + let input_tokens = usage + .get("prompt_tokens") + .and_then(Value::as_u64) + .expect("prompt tokens") as u32; + let output_tokens = usage + .get("completion_tokens") + .and_then(Value::as_u64) + .expect("completion tokens") as u32; + let prompt_cache_hit_tokens = usage + .get("prompt_cache_hit_tokens") + .and_then(Value::as_u64) + .map(|v| v as u32); + let prompt_cache_miss_tokens = usage + .get("prompt_cache_miss_tokens") + .and_then(Value::as_u64) + .map(|v| v as u32); + let reasoning_tokens = usage + .get("completion_tokens_details") + .and_then(|d| d.get("reasoning_tokens")) + .and_then(Value::as_u64) + .map(|v| v as u32); + + Usage { + input_tokens, + output_tokens, + prompt_cache_hit_tokens, + prompt_cache_miss_tokens, + reasoning_tokens, + reasoning_replay_tokens: None, + server_tool_use: None, + } + } + + let usage = parse_usage(Some(&json!({ + "prompt_tokens": 100, + "completion_tokens": 20, + "prompt_cache_hit_tokens": 70, + "prompt_cache_miss_tokens": 30, + "completion_tokens_details": { + "reasoning_tokens": 12 + } + }))); + + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 20); + assert_eq!(usage.prompt_cache_hit_tokens, Some(70)); + assert_eq!(usage.prompt_cache_miss_tokens, Some(30)); + assert_eq!(usage.reasoning_tokens, Some(12)); + } + + #[test] + fn sanitize_thinking_mode_counts_reasoning_replay_across_assistant_turns() { + // Multi-turn body that mimics two prior tool-calling rounds: each + // assistant message carries its `reasoning_content`. The sanitizer + // should keep all of them and the count helper should tally bytes + // across every assistant message. + let mut body = json!({ + "model": "deepseek-v4-pro", + "messages": [ + { "role": "system", "content": "you are helpful" }, + { "role": "user", "content": "step 1" }, + { + "role": "assistant", + "content": "", + "reasoning_content": "I need to call tool A first.", + "tool_calls": [{ "id": "1", "type": "function" }] + }, + { "role": "tool", "tool_call_id": "1", "content": "ok" }, + { + "role": "assistant", + "content": "", + "reasoning_content": "Now I call tool B.", + "tool_calls": [{ "id": "2", "type": "function" }] + }, + { "role": "tool", "tool_call_id": "2", "content": "ok" }, + { "role": "user", "content": "step 2" } + ] + }); + + let approx_tokens = + sanitize_thinking_mode_messages(&mut body, "deepseek-v4-pro", Some("max")) + .expect("multi-turn thinking-mode conversation should report replay tokens"); + // ~4 chars/token; 46 bytes of reasoning -> 11 tokens. + assert_eq!(approx_tokens, 11); + + let chars = count_reasoning_replay_chars(&body); + // "I need to call tool A first." (28) + "Now I call tool B." (18) = 46 + assert_eq!(chars, 46); + + // No assistant messages should have lost or had their reasoning_content blanked. + let messages = body["messages"].as_array().unwrap(); + let assistant_with_reasoning: usize = messages + .iter() + .filter(|m| m["role"] == "assistant") + .filter(|m| { + m["reasoning_content"] + .as_str() + .is_some_and(|s| !s.is_empty()) + }) + .count(); + assert_eq!(assistant_with_reasoning, 2); + } + + /// Issue #30: when no thinking-mode replay applies (non-thinking model or + /// empty conversation), the sanitizer returns `None` so the footer chip + /// stays hidden. + #[test] + fn sanitize_thinking_mode_returns_none_for_non_thinking_model() { + let mut body = json!({ + "model": "deepseek-chat", + "messages": [ + { "role": "user", "content": "hi" } + ] + }); + let result = sanitize_thinking_mode_messages(&mut body, "deepseek-chat", None); + assert!(result.is_none()); + } + + #[test] + fn sanitize_thinking_mode_counts_substituted_placeholder() { + // An assistant tool-call message is missing reasoning_content; the + // sanitizer must inject the placeholder, and the count helper must + // include the placeholder in the total (since it's in the wire + // payload that ships to DeepSeek). + let mut body = json!({ + "model": "deepseek-v4-pro", + "messages": [ + { "role": "user", "content": "hi" }, + { + "role": "assistant", + "content": "", + "tool_calls": [{ "id": "1", "type": "function" }] + } + ] + }); + + sanitize_thinking_mode_messages(&mut body, "deepseek-v4-pro", Some("max")); + + let chars = count_reasoning_replay_chars(&body); + // "(reasoning omitted)" is 19 bytes. + assert_eq!(chars, 19); + } + + #[test] + fn token_bucket_enforces_delay_when_empty() { + let now = Instant::now(); + let mut bucket = TokenBucket { + enabled: true, + capacity: 1.0, + tokens: 1.0, + refill_per_sec: 2.0, + last_refill: now, + }; + + assert!(bucket.delay_until_available(1.0).is_none()); + let delay = bucket + .delay_until_available(1.0) + .expect("bucket should require refill delay"); + assert!( + delay >= Duration::from_millis(400) && delay <= Duration::from_millis(600), + "unexpected refill delay: {delay:?}" + ); + } + + #[test] + fn stream_buffer_pool_reuses_released_buffers() { + let mut first = acquire_stream_buffer(); + first.extend_from_slice(b"hello"); + let released_capacity = first.capacity(); + release_stream_buffer(first); + + let second = acquire_stream_buffer(); + assert!(second.is_empty()); + assert!( + second.capacity() >= released_capacity, + "pooled buffer capacity should be reused" + ); + } + + #[test] + fn base_url_security_rejects_insecure_non_local_http() { + let err = validate_base_url_security("http://api.deepseek.com") + .expect_err("non-local insecure HTTP should be rejected"); + assert!(err.to_string().contains("Refusing insecure base URL")); + } + + #[test] + fn base_url_security_allows_localhost_http() { + assert!(validate_base_url_security("http://localhost:8080").is_ok()); + assert!(validate_base_url_security("http://127.0.0.1:8080").is_ok()); + } + + #[test] + fn connection_health_degrades_and_recovers() { + let now = Instant::now(); + let mut health = ConnectionHealth::default(); + assert_eq!(health.state, ConnectionState::Healthy); + + apply_request_failure(&mut health, now); + assert_eq!(health.state, ConnectionState::Healthy); + + apply_request_failure(&mut health, now + Duration::from_millis(1)); + assert_eq!(health.state, ConnectionState::Degraded); + assert_eq!(health.consecutive_failures, 2); + + let recovered = apply_request_success(&mut health, now + Duration::from_secs(1)); + assert!(recovered); + assert_eq!(health.state, ConnectionState::Healthy); + assert_eq!(health.consecutive_failures, 0); + } + + #[test] + fn recovery_probe_respects_cooldown() { + let now = Instant::now(); + let mut health = ConnectionHealth { + state: ConnectionState::Degraded, + ..ConnectionHealth::default() + }; + + assert!(mark_recovery_probe_if_due(&mut health, now)); + assert_eq!(health.state, ConnectionState::Recovering); + assert!(!mark_recovery_probe_if_due( + &mut health, + now + Duration::from_secs(1) + )); + assert!(mark_recovery_probe_if_due( + &mut health, + now + RECOVERY_PROBE_COOLDOWN + Duration::from_millis(1) + )); + } + + // === #103 Phase 2: HTTP/1 escape hatch =================================== + + /// Serialize tests that mutate `DEEPSEEK_FORCE_HTTP1` so they don't race + /// against each other — env vars are process-global. + static FORCE_HTTP1_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct ForceHttp1EnvGuard { + prior: Option, + } + impl ForceHttp1EnvGuard { + fn capture() -> Self { + Self { + prior: std::env::var_os("DEEPSEEK_FORCE_HTTP1"), + } + } + } + impl Drop for ForceHttp1EnvGuard { + fn drop(&mut self) { + // Safety: scoped to test process; reverts to the captured value. + match &self.prior { + Some(v) => unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", v) }, + None => unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") }, + } + } + } + + #[test] + fn force_http1_unset_is_false() { + let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap(); + let _guard = ForceHttp1EnvGuard::capture(); + unsafe { std::env::remove_var("DEEPSEEK_FORCE_HTTP1") }; + assert!(!force_http1_from_env()); + } + + #[test] + fn force_http1_truthy_values() { + let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap(); + let _guard = ForceHttp1EnvGuard::capture(); + for value in ["1", "true", "True", "YES", "on", " 1 "] { + // Safety: serialized by FORCE_HTTP1_ENV_LOCK; reverted by guard. + unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) }; + assert!( + force_http1_from_env(), + "{value:?} should be parsed as truthy", + ); + } + } + + #[test] + fn force_http1_falsy_values() { + let _lock = FORCE_HTTP1_ENV_LOCK.lock().unwrap(); + let _guard = ForceHttp1EnvGuard::capture(); + for value in ["0", "false", "no", "off", "", "garbage", "2"] { + unsafe { std::env::set_var("DEEPSEEK_FORCE_HTTP1", value) }; + assert!( + !force_http1_from_env(), + "{value:?} should NOT be parsed as truthy", + ); + } + } +} diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index ca92827f..89e61916 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -163,6 +163,15 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult { HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()), HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)), HistoryCell::SubAgent(sub) => ("**Sub-agent:**", render_subagent_cell(sub, 80)), + HistoryCell::ArchivedContext { + level, + range, + summary, + .. + } => ( + "**Archived Context:**", + format!("L{level} [{range}]: {summary}"), + ), }; let _ = write!(content, "{}\n\n{}\n\n---\n\n", role, body.trim()); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 2ea12471..0d4a28f6 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -25,6 +25,11 @@ pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; pub const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; +pub const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; +pub const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; +pub const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; +pub const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; +pub const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1"; const API_KEYRING_SENTINEL: &str = "__KEYRING__"; pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[ "deepseek-v4-pro", @@ -41,6 +46,8 @@ pub enum ApiProvider { NvidiaNim, Openrouter, Novita, + Fireworks, + Sglang, } impl ApiProvider { @@ -51,6 +58,8 @@ impl ApiProvider { "nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim), "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), + "fireworks" | "fireworks-ai" => Some(Self::Fireworks), + "sglang" | "sg-lang" => Some(Self::Sglang), _ => None, } } @@ -62,6 +71,8 @@ impl ApiProvider { Self::NvidiaNim => "nvidia-nim", Self::Openrouter => "openrouter", Self::Novita => "novita", + Self::Fireworks => "fireworks", + Self::Sglang => "sglang", } } @@ -73,6 +84,8 @@ impl ApiProvider { Self::NvidiaNim => "NVIDIA NIM", Self::Openrouter => "OpenRouter", Self::Novita => "Novita AI", + Self::Fireworks => "Fireworks AI", + Self::Sglang => "SGLang", } } @@ -84,6 +97,8 @@ impl ApiProvider { Self::NvidiaNim, Self::Openrouter, Self::Novita, + Self::Fireworks, + Self::Sglang, ] } } @@ -688,6 +703,10 @@ pub struct ProvidersConfig { pub openrouter: ProviderConfig, #[serde(default)] pub novita: ProviderConfig, + #[serde(default)] + pub fireworks: ProviderConfig, + #[serde(default)] + pub sglang: ProviderConfig, } #[derive(Debug, Clone, Deserialize, Default)] @@ -747,7 +766,7 @@ impl Config { && ApiProvider::parse(provider).is_none() { anyhow::bail!( - "Invalid provider '{provider}': expected deepseek, nvidia-nim, openrouter, or novita." + "Invalid provider '{provider}': expected deepseek, nvidia-nim, openrouter, novita, fireworks, or sglang." ); } if let Some(ref key) = self.api_key @@ -855,6 +874,8 @@ impl Config { ApiProvider::NvidiaNim => &providers.nvidia_nim, ApiProvider::Openrouter => &providers.openrouter, ApiProvider::Novita => &providers.novita, + ApiProvider::Fireworks => &providers.fireworks, + ApiProvider::Sglang => &providers.sglang, }) } @@ -883,6 +904,8 @@ impl Config { ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL, ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL, ApiProvider::Novita => DEFAULT_NOVITA_MODEL, + ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL, + ApiProvider::Sglang => DEFAULT_SGLANG_MODEL, } .to_string() } @@ -905,7 +928,8 @@ impl Config { .as_ref() .filter(|base| base.contains("integrate.api.nvidia.com")) .cloned(), - ApiProvider::Openrouter | ApiProvider::Novita => None, + ApiProvider::Openrouter | ApiProvider::Novita + | ApiProvider::Fireworks | ApiProvider::Sglang => None, }; let base = provider_base.or(root_base).unwrap_or_else(|| { match provider { @@ -913,6 +937,8 @@ impl Config { ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, + ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, } .to_string() }); @@ -932,6 +958,8 @@ impl Config { ApiProvider::NvidiaNim => "nvidia-nim", ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", + ApiProvider::Fireworks => "fireworks", + ApiProvider::Sglang => "sglang", }; // 1. OS keyring + 2. environment variables (handled by Secrets). @@ -986,6 +1014,15 @@ impl Config { "Novita API key not found. Run 'deepseek auth set --provider novita', \ set NOVITA_API_KEY, or add [providers.novita] api_key in ~/.deepseek/config.toml." ), + ApiProvider::Fireworks => anyhow::bail!( + "Fireworks AI API key not found. Run 'deepseek auth set --provider fireworks', \ + set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml." + ), + ApiProvider::Sglang => anyhow::bail!( + "SGLang API key not found (optional for self-hosted). Run 'deepseek auth set --provider sglang', \ + set SGLANG_API_KEY, or add [providers.sglang] api_key in ~/.deepseek/config.toml. \ + If your SGLang deployment runs without authentication, set SGLANG_API_KEY to an empty string or any placeholder." + ), } } @@ -1300,6 +1337,31 @@ fn apply_env_overrides(config: &mut Config) { .novita .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::Fireworks) + && let Ok(value) = std::env::var("FIREWORKS_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .fireworks + .base_url = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Sglang) + && let Ok(value) = std::env::var("SGLANG_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .sglang + .base_url = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Sglang) + && let Ok(value) = std::env::var("SGLANG_MODEL") + { + config.default_text_model = Some(value); + } if let Ok(value) = std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) { @@ -1485,6 +1547,16 @@ fn normalize_model_config(config: &mut Config) { { providers.novita.model = Some(normalized); } + if let Some(model) = providers.fireworks.model.as_deref() + && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model) + { + providers.fireworks.model = Some(normalized); + } + if let Some(model) = providers.sglang.model.as_deref() + && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model) + { + providers.sglang.model = Some(normalized); + } } } @@ -1502,6 +1574,13 @@ fn model_for_provider(provider: ApiProvider, normalized: String) -> String { } (ApiProvider::Novita, "deepseek-v4-pro") => DEFAULT_NOVITA_MODEL.to_string(), (ApiProvider::Novita, "deepseek-v4-flash") => DEFAULT_NOVITA_FLASH_MODEL.to_string(), + (ApiProvider::Fireworks, "deepseek-v4-pro") => DEFAULT_FIREWORKS_MODEL.to_string(), + (ApiProvider::Fireworks, "deepseek-v4-flash") => { + // Flash not yet available on Fireworks; fall through to normalized name + "accounts/fireworks/models/deepseek-v4-flash".to_string() + } + (ApiProvider::Sglang, "deepseek-v4-pro") => DEFAULT_SGLANG_MODEL.to_string(), + (ApiProvider::Sglang, "deepseek-v4-flash") => DEFAULT_SGLANG_FLASH_MODEL.to_string(), _ => normalized, } } @@ -1618,6 +1697,8 @@ fn merge_providers( nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim), openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter), novita: merge_provider_config(base.novita, override_cfg.novita), + fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks), + sglang: merge_provider_config(base.sglang, override_cfg.sglang), }), } } @@ -1821,6 +1902,8 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { ApiProvider::NvidiaNim => "NVIDIA_API_KEY", ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", + ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Sglang => "SGLANG_API_KEY", }; if std::env::var(env_var).is_ok_and(|k| !k.trim().is_empty()) { return true; @@ -1831,12 +1914,19 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { return true; } + // SGLang is self-hosted and typically runs without authentication. + if matches!(provider, ApiProvider::Sglang) { + return true; + } + if let Some(providers) = config.providers.as_ref() { let entry = match provider { ApiProvider::Deepseek => &providers.deepseek, ApiProvider::NvidiaNim => &providers.nvidia_nim, ApiProvider::Openrouter => &providers.openrouter, ApiProvider::Novita => &providers.novita, + ApiProvider::Fireworks => &providers.fireworks, + ApiProvider::Sglang => &providers.sglang, }; if entry .api_key @@ -1873,6 +1963,8 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::NvidiaNim => "providers.nvidia_nim", ApiProvider::Openrouter => "providers.openrouter", ApiProvider::Novita => "providers.novita", + ApiProvider::Fireworks => "providers.fireworks", + ApiProvider::Sglang => "providers.sglang", }; // Parse existing TOML (or start fresh) so we can edit the right table @@ -1898,6 +1990,8 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::NvidiaNim => "nvidia_nim", ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", + ApiProvider::Fireworks => "fireworks", + ApiProvider::Sglang => "sglang", }; let entry = providers .entry(key_inside.to_string()) @@ -1987,6 +2081,11 @@ mod tests { openrouter_base_url: Option, novita_api_key: Option, novita_base_url: Option, + fireworks_api_key: Option, + fireworks_base_url: Option, + sglang_api_key: Option, + sglang_base_url: Option, + sglang_model: Option, } impl EnvGuard { @@ -2012,6 +2111,11 @@ mod tests { let openrouter_base_url_prev = env::var_os("OPENROUTER_BASE_URL"); let novita_api_key_prev = env::var_os("NOVITA_API_KEY"); let novita_base_url_prev = env::var_os("NOVITA_BASE_URL"); + let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY"); + let fireworks_base_url_prev = env::var_os("FIREWORKS_BASE_URL"); + let sglang_api_key_prev = env::var_os("SGLANG_API_KEY"); + let sglang_base_url_prev = env::var_os("SGLANG_BASE_URL"); + let sglang_model_prev = env::var_os("SGLANG_MODEL"); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("HOME", &home_str); @@ -2032,6 +2136,11 @@ mod tests { env::remove_var("OPENROUTER_BASE_URL"); env::remove_var("NOVITA_API_KEY"); env::remove_var("NOVITA_BASE_URL"); + env::remove_var("FIREWORKS_API_KEY"); + env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("SGLANG_API_KEY"); + env::remove_var("SGLANG_BASE_URL"); + env::remove_var("SGLANG_MODEL"); } Self { home: home_prev, @@ -2052,6 +2161,11 @@ mod tests { openrouter_base_url: openrouter_base_url_prev, novita_api_key: novita_api_key_prev, novita_base_url: novita_base_url_prev, + fireworks_api_key: fireworks_api_key_prev, + fireworks_base_url: fireworks_base_url_prev, + sglang_api_key: sglang_api_key_prev, + sglang_base_url: sglang_base_url_prev, + sglang_model: sglang_model_prev, } } } @@ -2081,6 +2195,11 @@ mod tests { Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); + Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); + Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take()); + Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take()); + Self::restore_var("SGLANG_MODEL", self.sglang_model.take()); } } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index fe499f11..cde15eee 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -24,6 +24,7 @@ use crate::compaction::{ CompactionConfig, compact_messages_safe, estimate_tokens, merge_system_prompts, should_compact, }; use crate::config::{Config, DEFAULT_MAX_SUBAGENTS, DEFAULT_TEXT_MODEL}; +use crate::seam_manager::{SeamConfig, SeamManager}; use crate::cycle_manager::{ CycleBriefing, CycleConfig, StructuredState, archive_cycle, build_seed_messages, estimate_briefing_tokens, produce_briefing, should_advance_cycle, @@ -265,6 +266,9 @@ pub struct Engine { shared_cancel_token: Arc>, tool_exec_lock: Arc>, capacity_controller: CapacityController, + /// Append-only layered context manager (#159). Produces soft seams at + /// 192K/384K/576K and Flash-cycle briefings at 768K. + seam_manager: Option, coherence_state: CoherenceState, turn_counter: u64, /// Post-edit LSP diagnostics injection (#136). Populated unconditionally @@ -1254,6 +1258,36 @@ impl Engine { let shell_manager = new_shared_shell_manager(config.workspace.clone()); let capacity_controller = CapacityController::new(config.capacity.clone()); + // Create Flash seam manager for layered context (#159). Uses the same + // API credentials as the main client but targets the Flash model for + // cost-effective summarisation and cycle briefing work. + let seam_manager = deepseek_client.as_ref().map(|main_client| { + let seam_config = SeamConfig { + enabled: api_config.context.enabled.unwrap_or(true), + verbatim_window_turns: api_config.context.verbatim_window_turns.unwrap_or( + crate::seam_manager::VERBATIM_WINDOW_TURNS, + ), + l1_threshold: api_config.context.l1_threshold.unwrap_or( + crate::seam_manager::DEFAULT_L1_THRESHOLD, + ), + l2_threshold: api_config.context.l2_threshold.unwrap_or( + crate::seam_manager::DEFAULT_L2_THRESHOLD, + ), + l3_threshold: api_config.context.l3_threshold.unwrap_or( + crate::seam_manager::DEFAULT_L3_THRESHOLD, + ), + cycle_threshold: api_config.context.cycle_threshold.unwrap_or( + crate::seam_manager::DEFAULT_CYCLE_THRESHOLD, + ), + seam_model: api_config + .context + .seam_model + .clone() + .unwrap_or_else(|| crate::seam_manager::DEFAULT_SEAM_MODEL.to_string()), + }; + SeamManager::new(main_client.clone(), seam_config) + }); + let lsp_manager = Arc::new(match config.lsp_config.clone() { Some(cfg) => crate::lsp::LspManager::new(cfg, config.workspace.clone()), None => crate::lsp::LspManager::disabled(), @@ -1276,6 +1310,7 @@ impl Engine { shared_cancel_token: shared_cancel_token.clone(), tool_exec_lock, capacity_controller, + seam_manager, coherence_state: CoherenceState::default(), turn_counter: 0, lsp_manager, @@ -2378,7 +2413,117 @@ impl Engine { /// Handle a turn using the DeepSeek API. #[allow(clippy::too_many_lines)] - /// Run the checkpoint-restart cycle boundary if the session has crossed + /// Run the pre-request layered-context checkpoint (#159). Checks whether + /// cumulative tokens have crossed a soft-seam threshold and, if so, + /// produces an `` block via Flash and appends it as an + /// assistant message. Called from `handle_deepseek_turn` before each API + /// request so the model always has the latest navigation aids. + async fn layered_context_checkpoint(&mut self) { + let Some(ref seam_mgr) = self.seam_manager else { + return; + }; + if !seam_mgr.config().enabled { + return; + } + + // Cumulative tokens: session total (all turns so far) + current + // estimated input (the messages that will be sent next). + let cumulative_input = self + .session + .total_usage + .input_tokens + .saturating_add(self.session.total_usage.output_tokens); + let cumulative_estimate = + cumulative_input.saturating_add(self.estimated_input_tokens() as u64); + + let highest = seam_mgr.highest_level().await; + let Some(level) = seam_mgr.seam_level_for(cumulative_estimate as usize, highest) else { + return; + }; + + // Determine the message range to summarize: everything before the + // verbatim window. The verbatim window (last ~16 turns) stays + // untouched so the model always has ground-truth recent context. + let msg_count = self.session.messages.len(); + let verbatim_start = seam_mgr.verbatim_window_start(msg_count); + if verbatim_start == 0 { + return; // Not enough messages to summarize. + } + + let msg_range_end = verbatim_start; + let pinned = self + .session + .working_set + .pinned_message_indices(&self.session.messages, &self.session.workspace); + + let _ = self + .tx_event + .send(Event::status(format!( + "⏻ producing L{level} context seam ({msg_range_end} messages)…" + ))) + .await; + + // If we have existing seams, recompact; otherwise produce fresh. + let existing_seams = seam_mgr.collect_seam_texts(&self.session.messages).await; + let seam_text = if existing_seams.is_empty() { + match seam_mgr + .produce_soft_seam( + &self.session.messages, + level, + 0, + msg_range_end, + Some(&self.session.workspace), + &pinned, + ) + .await + { + Ok(text) => text, + Err(err) => { + crate::logging::warn(format!("L{level} soft seam failed: {err}")); + return; + } + } + } else { + let recent: Vec<&Message> = (0..msg_range_end) + .filter_map(|i| self.session.messages.get(i)) + .collect(); + match seam_mgr + .recompact(&existing_seams, &recent, level, 0, msg_range_end) + .await + { + Ok(text) => text, + Err(err) => { + crate::logging::warn(format!("L{level} recompact failed: {err}")); + return; + } + } + }; + + if seam_text.is_empty() { + return; + } + + // Capture seam count before the mutable borrow below. + let seam_count = seam_mgr.seam_count().await; + + // Append the seam as an assistant message. This is an append-only + // operation — no messages are deleted. The prefix cache stays hot. + self.add_session_message(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: seam_text, + cache_control: None, + }], + }) + .await; + + let _ = self + .tx_event + .send(Event::status(format!( + "⏻ L{level} seam complete ({seam_count} total, {msg_range_end} messages covered)" + ))) + .await; + } /// its token threshold (issue #124). No-op in the common case. /// /// Caller must invoke this only at a clean turn boundary (no in-flight @@ -2420,31 +2565,79 @@ impl Engine { ))) .await; - // 1. Generate the model-curated briefing. We do this *before* - // archiving so a briefing-call failure leaves the cycle intact — - // the user can keep working at higher token counts until the next - // boundary check, rather than losing their context to a failed - // handoff. - let briefing_text = match produce_briefing( - &client, - &self.session.model, - &self.session.messages, - max_briefing_tokens, - ) - .await - { - Ok(text) => text, - Err(err) => { - crate::logging::warn(format!( - "Cycle briefing turn failed; skipping cycle advance: {err}" - )); - let _ = self - .tx_event - .send(Event::status(format!( - "↻ cycle handoff failed (continuing in cycle {from}): {err}" - ))) - .await; - return; + // 1. Generate the model-curated briefing. Prefer the Flash seam + // manager (#159) for cost and speed; fall back to the main model + // (legacy produce_briefing) when the seam manager isn't available. + let briefing_text = if let Some(ref seam_mgr) = self.seam_manager { + let seams = seam_mgr.collect_seam_texts(&self.session.messages).await; + let state_text = { + let s = StructuredState::capture( + mode.label(), + self.config.workspace.clone(), + std::env::current_dir().ok(), + &self.session.working_set, + &self.config.todos, + &self.config.plan_state, + Some(&self.subagent_manager), + ) + .await; + s.to_system_block() + }; + match seam_mgr + .produce_flash_briefing(&seams, state_text.as_deref()) + .await + { + Ok(text) => text, + Err(err) => { + crate::logging::warn(format!( + "Flash briefing failed, falling back to main model: {err}" + )); + match produce_briefing( + &client, + &self.session.model, + &self.session.messages, + max_briefing_tokens, + ) + .await + { + Ok(text) => text, + Err(err2) => { + crate::logging::warn(format!( + "Cycle briefing turn failed; skipping cycle advance: {err2}" + )); + let _ = self + .tx_event + .send(Event::status(format!( + "↻ cycle handoff failed (continuing in cycle {from}): {err2}" + ))) + .await; + return; + } + } + } + } + } else { + match produce_briefing( + &client, + &self.session.model, + &self.session.messages, + max_briefing_tokens, + ) + .await + { + Ok(text) => text, + Err(err) => { + crate::logging::warn(format!( + "Cycle briefing turn failed; skipping cycle advance: {err}" + )); + let _ = self + .tx_event + .send(Event::status(format!( + "↻ cycle handoff failed (continuing in cycle {from}): {err}" + ))) + .await; + return; + } } }; @@ -2504,6 +2697,10 @@ impl Engine { self.session.cycle_count = to; self.session.current_cycle_started = now; self.session.cycle_briefings.push(briefing.clone()); + // Reset seam tracking for the new cycle. + if let Some(ref seam_mgr) = self.seam_manager { + seam_mgr.reset().await; + } // Drop any compaction summary — that path is incompatible with the // fresh-context model and would Frankenstein-merge with the briefing. self.session.compaction_summary_prompt = None; diff --git a/crates/tui/src/core/engine.rs.bak b/crates/tui/src/core/engine.rs.bak new file mode 100644 index 00000000..6f2b314e --- /dev/null +++ b/crates/tui/src/core/engine.rs.bak @@ -0,0 +1,2853 @@ +//! Core engine for `DeepSeek` CLI. +//! +//! The engine handles all AI interactions in a background task, +//! communicating with the UI via channels. This enables: +//! - Non-blocking UI during API calls +//! - Real-time streaming updates +//! - Proper cancellation support +//! - Tool execution orchestration + +use std::path::PathBuf; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant}; +use std::{fs::OpenOptions, io::Write}; + +use anyhow::Result; +use futures_util::StreamExt; +use futures_util::stream::FuturesUnordered; +use serde_json::json; +use tokio::sync::{Mutex as AsyncMutex, RwLock, mpsc}; +use tokio_util::sync::CancellationToken; + +use crate::client::DeepSeekClient; +use crate::compaction::{ + CompactionConfig, compact_messages_safe, estimate_tokens, merge_system_prompts, should_compact, +}; +use crate::config::{Config, DEFAULT_MAX_SUBAGENTS, DEFAULT_TEXT_MODEL}; +use crate::seam_manager::{SeamConfig, SeamManager}; +use crate::cycle_manager::{ + CycleBriefing, CycleConfig, StructuredState, archive_cycle, build_seed_messages, + estimate_briefing_tokens, produce_briefing, should_advance_cycle, +}; +use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, StreamError}; +use crate::features::{Feature, Features}; +use crate::llm_client::LlmClient; +use crate::mcp::McpPool; +use crate::models::{ + ContentBlock, ContentBlockStart, DEFAULT_CONTEXT_WINDOW_TOKENS, Delta, Message, MessageRequest, + StreamEvent, SystemBlock, SystemPrompt, Tool, ToolCaller, Usage, context_window_for_model, +}; +use crate::prompts; +use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; +use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; +use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult, required_str}; +use crate::tools::subagent::{ + Mailbox, SharedSubAgentManager, SubAgentRuntime, SubAgentType, new_shared_subagent_manager, +}; +use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; +use crate::tools::user_input::{UserInputRequest, UserInputResponse}; +use crate::tools::{ToolContext, ToolRegistryBuilder}; +use crate::tui::app::AppMode; + +use super::capacity::{ + CapacityController, CapacityControllerConfig, CapacityDecision, CapacityObservationInput, + CapacitySnapshot, GuardrailAction, RiskBand, +}; +use super::capacity_memory::{ + CanonicalState, CapacityMemoryRecord, ReplayInfo, append_capacity_record, + load_last_k_capacity_records, new_record_id, now_rfc3339, +}; +use super::coherence::{CoherenceSignal, CoherenceState, next_coherence_state}; +use super::events::{Event, TurnOutcomeStatus}; +use super::ops::Op; +use super::session::Session; +use super::tool_parser; +use super::turn::{TurnContext, TurnToolCall, post_turn_snapshot, pre_turn_snapshot}; + +// === Types === + +/// Configuration for the engine +#[derive(Debug, Clone)] +pub struct EngineConfig { + /// Model identifier to use for responses. + pub model: String, + /// Workspace root for tool execution and file operations. + pub workspace: PathBuf, + /// Allow shell tool execution when true. + pub allow_shell: bool, + /// Enable trust mode (skip approvals) when true. + pub trust_mode: bool, + /// Path to the notes file used by the notes tool. + pub notes_path: PathBuf, + /// Path to the MCP configuration file. + pub mcp_config_path: PathBuf, + /// Maximum number of assistant steps before stopping. + pub max_steps: u32, + /// Maximum number of concurrently active subagents. + pub max_subagents: usize, + /// Feature flags controlling tool availability. + pub features: Features, + /// Auto-compaction settings for long conversations. + /// + /// As of v0.6.6 the high-level summarization compaction (`compact_messages_safe`) + /// is **disabled by default**; the checkpoint-restart cycle architecture + /// (`cycle_manager`) replaces it. The compaction config is still wired through + /// for the per-tool-result truncation path (`compact_tool_result_for_context`) + /// and for users who explicitly opt back in via `[compaction] enabled = true`. + pub compaction: CompactionConfig, + /// Checkpoint-restart cycle settings (issue #124). + pub cycle: CycleConfig, + /// Capacity-controller settings. + pub capacity: CapacityControllerConfig, + /// Shared Todo list state. + pub todos: SharedTodoList, + /// Shared Plan state. + pub plan_state: SharedPlanState, + /// Maximum sub-agent recursion depth (default 3). See + /// `SubAgentRuntime::max_spawn_depth`. Override via + /// `[runtime] max_spawn_depth = N` in `~/.deepseek/config.toml`. + pub max_spawn_depth: u32, + /// Per-domain network policy decider (#135). Shared across the session so + /// session-scoped approvals (`/network allow `) persist for the + /// remainder of the run. + pub network_policy: Option, + /// Whether to take side-git workspace snapshots before/after each turn. + pub snapshots_enabled: bool, + /// Post-edit LSP diagnostics injection (#136). When `None`, the engine + /// constructs a disabled manager so the field is always present. + pub lsp_config: Option, +} + +impl Default for EngineConfig { + fn default() -> Self { + Self { + model: DEFAULT_TEXT_MODEL.to_string(), + workspace: PathBuf::from("."), + allow_shell: true, + trust_mode: false, + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + max_steps: 100, + max_subagents: DEFAULT_MAX_SUBAGENTS, + features: Features::with_defaults(), + compaction: CompactionConfig::default(), + cycle: CycleConfig::default(), + capacity: CapacityControllerConfig::default(), + todos: new_shared_todo_list(), + plan_state: new_shared_plan_state(), + max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, + network_policy: None, + snapshots_enabled: true, + lsp_config: None, + } + } +} + +/// Handle to communicate with the engine +#[derive(Clone)] +pub struct EngineHandle { + /// Send operations to the engine + pub tx_op: mpsc::Sender, + /// Receive events from the engine + pub rx_event: Arc>>, + /// Shared pointer to the cancellation token for the current request. + cancel_token: Arc>, + /// Send approval decisions to the engine + tx_approval: mpsc::Sender, + /// Send user input responses to the engine + tx_user_input: mpsc::Sender, + /// Send steer input for an in-flight turn. + tx_steer: mpsc::Sender, +} + +impl EngineHandle { + /// Send an operation to the engine + pub async fn send(&self, op: Op) -> Result<()> { + self.tx_op.send(op).await?; + Ok(()) + } + + /// Cancel the current request + pub fn cancel(&self) { + match self.cancel_token.lock() { + Ok(token) => token.cancel(), + Err(poisoned) => poisoned.into_inner().cancel(), + } + } + + /// Check if a request is currently cancelled + #[must_use] + #[allow(dead_code)] + pub fn is_cancelled(&self) -> bool { + match self.cancel_token.lock() { + Ok(token) => token.is_cancelled(), + Err(poisoned) => poisoned.into_inner().is_cancelled(), + } + } + + /// Approve a pending tool call + pub async fn approve_tool_call(&self, id: impl Into) -> Result<()> { + self.tx_approval + .send(ApprovalDecision::Approved { id: id.into() }) + .await?; + Ok(()) + } + + /// Deny a pending tool call + pub async fn deny_tool_call(&self, id: impl Into) -> Result<()> { + self.tx_approval + .send(ApprovalDecision::Denied { id: id.into() }) + .await?; + Ok(()) + } + + /// Retry a tool call with an elevated sandbox policy. + pub async fn retry_tool_with_policy( + &self, + id: impl Into, + policy: crate::sandbox::SandboxPolicy, + ) -> Result<()> { + self.tx_approval + .send(ApprovalDecision::RetryWithPolicy { + id: id.into(), + policy, + }) + .await?; + Ok(()) + } + + /// Submit a response for request_user_input. + pub async fn submit_user_input( + &self, + id: impl Into, + response: UserInputResponse, + ) -> Result<()> { + self.tx_user_input + .send(UserInputDecision::Submitted { + id: id.into(), + response, + }) + .await?; + Ok(()) + } + + /// Cancel a request_user_input prompt. + pub async fn cancel_user_input(&self, id: impl Into) -> Result<()> { + self.tx_user_input + .send(UserInputDecision::Cancelled { id: id.into() }) + .await?; + Ok(()) + } + + /// Steer an in-flight turn with additional user input. + pub async fn steer(&self, content: impl Into) -> Result<()> { + self.tx_steer.send(content.into()).await?; + Ok(()) + } +} + +// === Engine === + +/// The core engine that processes operations and emits events +pub struct Engine { + config: EngineConfig, + deepseek_client: Option, + deepseek_client_error: Option, + session: Session, + subagent_manager: SharedSubAgentManager, + shell_manager: SharedShellManager, + mcp_pool: Option>>, + rx_op: mpsc::Receiver, + rx_approval: mpsc::Receiver, + rx_user_input: mpsc::Receiver, + rx_steer: mpsc::Receiver, + tx_event: mpsc::Sender, + cancel_token: CancellationToken, + shared_cancel_token: Arc>, + tool_exec_lock: Arc>, + capacity_controller: CapacityController, + /// Append-only layered context manager (#159). Produces soft seams at + /// 192K/384K/576K and Flash-cycle briefings at 768K. + seam_manager: Option, + coherence_state: CoherenceState, + turn_counter: u64, + /// Post-edit LSP diagnostics injection (#136). Populated unconditionally + /// — when LSP is disabled in config, this is an inert manager that + /// always returns `None` from `diagnostics_for`. + lsp_manager: Arc, + /// Diagnostics collected during the current step's tool calls. Drained + /// and forwarded as a synthetic user message before the next API call. + pending_lsp_blocks: Vec, +} + +// === Internal stream helpers === + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ContentBlockKind { + Text, + Thinking, + ToolUse, +} + +#[derive(Debug, Clone)] +struct ToolUseState { + id: String, + name: String, + input: serde_json::Value, + caller: Option, + input_buffer: String, +} + +/// Maximum time to wait for a single stream chunk before assuming a stall. +/// **This is the idle timeout** — it resets on every SSE chunk, so long +/// thinking turns that ARE producing reasoning_content stay alive. Only a +/// genuine `chunk_timeout` window of silence kills the stream. +const STREAM_CHUNK_TIMEOUT_SECS: u64 = 90; +/// Maximum total bytes of text/thinking content before aborting the stream. +const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB +/// Sanity backstop for total stream wall-clock duration. **Not** a routine +/// kill switch — `STREAM_CHUNK_TIMEOUT_SECS` (idle) is the primary stall +/// detector. The wall-clock cap is here only to bound pathological cases +/// (e.g. a server that keeps sending heartbeats forever without progress). +/// +/// History: this used to be 300s (5 min) which was too aggressive — V4 +/// thinking turns on hard prompts legitimately exceed 5 minutes wall-clock +/// while still emitting reasoning_content chunks the whole way. Bumped to +/// 30 min in v0.6.6 to address `TODO_FIXES.md` #1. Codex defaults to a +/// per-chunk idle of 300s with no wall-clock cap; we keep both layers but +/// give the wall-clock a generous window so it never fires in practice. +const STREAM_MAX_DURATION_SECS: u64 = 1800; // 30 minutes (was 300s; #103/#1) +/// Hard cap on consecutive recoverable stream errors before we surface a turn +/// failure. Bumped 3 → 5 in v0.6.7 along with the HTTP/2 keepalive defaults +/// (#103) — keepalive should make spurious decode errors rarer, so we can +/// tolerate a longer streak before giving up on the turn. +const MAX_STREAM_ERRORS_BEFORE_FAIL: u32 = 5; +/// Cap on transparent stream-level retries — these only happen when the wire +/// dies before any content was streamed, so DeepSeek hasn't billed us and +/// the user hasn't seen anything. Two attempts is enough to ride out a +/// flaky edge node without amplifying real outages (#103). +const MAX_TRANSPARENT_STREAM_RETRIES: u32 = 2; + +/// Decide whether a stream error is eligible for a transparent retry. +/// +/// True only when ALL three conditions hold: +/// 1. No content has been received on the current attempt — otherwise DeepSeek +/// has already billed us for output tokens and the user has seen partial +/// deltas; resending would double-bill and desync the UI. +/// 2. We still have transparent-retry budget remaining. +/// 3. The turn has not been cancelled. +/// +/// Extracted as a pure function so the four #103 retry cases can be exercised +/// in unit tests without booting the full engine state machine. +fn should_transparently_retry_stream( + any_content_received: bool, + transparent_attempts: u32, + cancelled: bool, +) -> bool { + !any_content_received && transparent_attempts < MAX_TRANSPARENT_STREAM_RETRIES && !cancelled +} +/// Max output tokens requested for normal agent turns. Generous on purpose: +/// V4 thinking models can produce tens of thousands of reasoning tokens on +/// hard prompts before the visible reply, and DeepSeek V4 ships with a 1M +/// context window. 256K leaves the model effectively unconstrained on +/// output without us imposing artificial per-turn caps that surfaced as the +/// assistant "stopping mid-response" when reasoning consumed the budget. +const TURN_MAX_OUTPUT_TOKENS: u32 = 262_144; +/// Keep this many most recent messages when emergency trimming is required. +const MIN_RECENT_MESSAGES_TO_KEEP: usize = 4; +/// Allow a few emergency recovery attempts before failing the turn. +const MAX_CONTEXT_RECOVERY_ATTEMPTS: u8 = 2; +/// Reserve additional headroom to avoid hitting provider hard limits. +const CONTEXT_HEADROOM_TOKENS: usize = 1024; +/// Hard cap for any tool output inserted into model context. +const TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS: usize = 12_000; +/// Soft cap for known noisy tools inserted into model context. +const TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS: usize = 2_000; +/// Snippet length kept when compacting tool output for model context. +const TOOL_RESULT_CONTEXT_SNIPPET_CHARS: usize = 900; +/// Hard cap for tool output inserted into a large-context model. +const LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS: usize = 180_000; +/// Soft cap for known noisy tools inserted into a large-context model. +const LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS: usize = 60_000; +/// Snippet length kept when compacting large-context tool output. +const LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS: usize = 40_000; +/// Context window size at which tool output limits can be relaxed. +const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000; +/// Max chars to keep from metadata-provided output summaries. +const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320; +const COMPACTION_SUMMARY_MARKER: &str = "Conversation Summary (Auto-Generated)"; +const WORKING_SET_SUMMARY_MARKER: &str = "## Repo Working Set"; + +pub(crate) const TOOL_CALL_START_MARKERS: [&str; 5] = [ + "[TOOL_CALL]", + "", +]; + +const MULTI_TOOL_PARALLEL_NAME: &str = "multi_tool_use.parallel"; +const REQUEST_USER_INPUT_NAME: &str = "request_user_input"; +const CODE_EXECUTION_TOOL_NAME: &str = "code_execution"; +const CODE_EXECUTION_TOOL_TYPE: &str = "code_execution_20250825"; +const TOOL_SEARCH_REGEX_NAME: &str = "tool_search_tool_regex"; +const TOOL_SEARCH_REGEX_TYPE: &str = "tool_search_tool_regex_20251119"; +const TOOL_SEARCH_BM25_NAME: &str = "tool_search_tool_bm25"; +const TOOL_SEARCH_BM25_TYPE: &str = "tool_search_tool_bm25_20251119"; +pub(crate) const TOOL_CALL_END_MARKERS: [&str; 5] = [ + "[/TOOL_CALL]", + "", + "", + "", + "", +]; + +/// Compact one-shot notice emitted when a model attempts to forge a tool-call +/// wrapper in plain text instead of using the API tool channel. The visible +/// content is still scrubbed; this exists so the user can see why their text +/// shrank. +pub(crate) const FAKE_WRAPPER_NOTICE: &str = + "Stripped non-API tool-call wrapper from model output (use the API tool channel)"; + +/// True if `text` contains any of the known fake-wrapper start markers. Used by +/// the streaming loop to decide whether to emit `FAKE_WRAPPER_NOTICE`. +pub(crate) fn contains_fake_tool_wrapper(text: &str) -> bool { + TOOL_CALL_START_MARKERS.iter().any(|m| text.contains(m)) +} + +fn find_first_marker(text: &str, markers: &[&str]) -> Option<(usize, usize)> { + markers + .iter() + .filter_map(|marker| text.find(marker).map(|idx| (idx, marker.len()))) + .min_by_key(|(idx, _)| *idx) +} + +pub(crate) fn filter_tool_call_delta(delta: &str, in_tool_call: &mut bool) -> String { + if delta.is_empty() { + return String::new(); + } + + let mut output = String::new(); + let mut rest = delta; + + loop { + if *in_tool_call { + let Some((idx, len)) = find_first_marker(rest, &TOOL_CALL_END_MARKERS) else { + break; + }; + rest = &rest[idx + len..]; + *in_tool_call = false; + } else { + let Some((idx, len)) = find_first_marker(rest, &TOOL_CALL_START_MARKERS) else { + output.push_str(rest); + break; + }; + output.push_str(&rest[..idx]); + rest = &rest[idx + len..]; + *in_tool_call = true; + } + } + + output +} + +/// Compute the tool input that should be reported when a tool's stream block +/// closes (`ContentBlockStop`). Prefers the parsed `input_buffer` over the +/// initial `input` placeholder so a `ToolCallStarted` event never carries a +/// stale `{}` when args were actually streamed in via `InputJsonDelta`. +/// +/// Order of preference: +/// 1. `input_buffer` parses cleanly → use that. +/// 2. `input_buffer` is empty → fall back to `input` (model embedded args +/// directly in the `ContentBlockStart` frame and sent no deltas). +/// 3. `input_buffer` non-empty but unparseable → fall back to `input` +/// (the per-delta parser has already mirrored the most recent valid +/// partial parse into `tool_state.input`). +fn is_tool_search_tool(name: &str) -> bool { + matches!(name, TOOL_SEARCH_REGEX_NAME | TOOL_SEARCH_BM25_NAME) +} + +fn should_default_defer_tool(name: &str, mode: AppMode) -> bool { + if mode == AppMode::Yolo { + return false; + } + + // Shell tools are kept active in Agent so the model can run verification + // commands (build/test/git/cargo) without first having to discover the + // tool through ToolSearch. Plan mode never registers shell tools. + let always_loaded_in_action_modes = matches!(mode, AppMode::Agent) + && matches!( + name, + "exec_shell" + | "exec_shell_wait" + | "exec_shell_interact" + | "exec_wait" + | "exec_interact" + ); + if always_loaded_in_action_modes { + return false; + } + + !matches!( + name, + "read_file" + | "list_dir" + | "grep_files" + | "file_search" + | "diagnostics" + | "rlm" + | "recall_archive" + | MULTI_TOOL_PARALLEL_NAME + | "update_plan" + | "todo_write" + | REQUEST_USER_INPUT_NAME + ) +} + +fn ensure_advanced_tooling(catalog: &mut Vec) { + if !catalog.iter().any(|t| t.name == CODE_EXECUTION_TOOL_NAME) { + catalog.push(Tool { + tool_type: Some(CODE_EXECUTION_TOOL_TYPE.to_string()), + name: CODE_EXECUTION_TOOL_NAME.to_string(), + description: "Execute Python code in a local sandboxed runtime and return stdout/stderr/return_code as JSON.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "code": { "type": "string", "description": "Python source code to execute." } + }, + "required": ["code"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } + + if !catalog.iter().any(|t| t.name == TOOL_SEARCH_REGEX_NAME) { + catalog.push(Tool { + tool_type: Some(TOOL_SEARCH_REGEX_TYPE.to_string()), + name: TOOL_SEARCH_REGEX_NAME.to_string(), + description: "Search deferred tool definitions using a regex query and return matching tool references.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Regex pattern to search tool names/descriptions/schema." } + }, + "required": ["query"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } + + if !catalog.iter().any(|t| t.name == TOOL_SEARCH_BM25_NAME) { + catalog.push(Tool { + tool_type: Some(TOOL_SEARCH_BM25_TYPE.to_string()), + name: TOOL_SEARCH_BM25_NAME.to_string(), + description: "Search deferred tool definitions using natural-language matching and return matching tool references.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Natural language query for tool discovery." } + }, + "required": ["query"] + }), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }); + } +} + +fn initial_active_tools(catalog: &[Tool]) -> std::collections::HashSet { + let mut active = std::collections::HashSet::new(); + for tool in catalog { + if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) { + active.insert(tool.name.clone()); + } + } + if active.is_empty() + && !catalog.is_empty() + && let Some(first) = catalog.first() + { + active.insert(first.name.clone()); + } + active +} + +fn active_tool_list_from_catalog( + catalog: &[Tool], + active: &std::collections::HashSet, +) -> Vec { + catalog + .iter() + .filter(|tool| active.contains(&tool.name)) + .cloned() + .collect() +} + +fn active_tools_for_step( + catalog: &[Tool], + active: &std::collections::HashSet, + force_update_plan: bool, +) -> Vec { + // DeepSeek reasoning models reject explicit named tool_choice forcing here, so for + // obvious quick-plan asks we narrow the first-step tool surface to update_plan instead. + if force_update_plan { + let forced: Vec<_> = catalog + .iter() + .filter(|tool| tool.name == "update_plan") + .cloned() + .collect(); + if !forced.is_empty() { + return forced; + } + } + + active_tool_list_from_catalog(catalog, active) +} + +fn tool_search_haystack(tool: &Tool) -> String { + format!( + "{}\n{}\n{}", + tool.name.to_lowercase(), + tool.description.to_lowercase(), + tool.input_schema.to_string().to_lowercase() + ) +} + +fn discover_tools_with_regex(catalog: &[Tool], query: &str) -> Result, ToolError> { + let regex = regex::Regex::new(query) + .map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?; + + let mut matches = Vec::new(); + for tool in catalog { + if is_tool_search_tool(&tool.name) { + continue; + } + let hay = tool_search_haystack(tool); + if regex.is_match(&hay) { + matches.push(tool.name.clone()); + } + if matches.len() >= 5 { + break; + } + } + Ok(matches) +} + +fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str) -> Vec { + let terms: Vec = query + .split_whitespace() + .map(|term| term.trim().to_lowercase()) + .filter(|term| !term.is_empty()) + .collect(); + if terms.is_empty() { + return Vec::new(); + } + + let mut scored: Vec<(i64, String)> = Vec::new(); + for tool in catalog { + if is_tool_search_tool(&tool.name) { + continue; + } + let hay = tool_search_haystack(tool); + let mut score = 0i64; + for term in &terms { + if hay.contains(term) { + score += 1; + } + if tool.name.to_lowercase().contains(term) { + score += 2; + } + } + if score > 0 { + scored.push((score, tool.name.clone())); + } + } + scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + scored.into_iter().take(5).map(|(_, name)| name).collect() +} + +fn edit_distance(a: &str, b: &str) -> usize { + if a == b { + return 0; + } + if a.is_empty() { + return b.chars().count(); + } + if b.is_empty() { + return a.chars().count(); + } + + let b_chars: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b_chars.len()).collect(); + let mut curr = vec![0usize; b_chars.len() + 1]; + + for (i, a_ch) in a.chars().enumerate() { + curr[0] = i + 1; + for (j, b_ch) in b_chars.iter().enumerate() { + let cost = if a_ch == *b_ch { 0 } else { 1 }; + let delete = prev[j + 1] + 1; + let insert = curr[j] + 1; + let substitute = prev[j] + cost; + curr[j + 1] = delete.min(insert).min(substitute); + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[b_chars.len()] +} + +fn suggest_tool_names(catalog: &[Tool], requested: &str, limit: usize) -> Vec { + let requested = requested.trim().to_ascii_lowercase(); + if requested.is_empty() || limit == 0 { + return Vec::new(); + } + + let mut candidates: Vec<(u8, usize, String)> = Vec::new(); + for tool in catalog { + let candidate = tool.name.to_ascii_lowercase(); + let prefix_match = candidate.starts_with(&requested) || requested.starts_with(&candidate); + let contains_match = candidate.contains(&requested) || requested.contains(&candidate); + let distance = edit_distance(&candidate, &requested); + let close_typo = distance <= 3; + + if !(prefix_match || contains_match || close_typo) { + continue; + } + + let rank = if prefix_match { + 0 + } else if contains_match { + 1 + } else { + 2 + }; + candidates.push((rank, distance, tool.name.clone())); + } + + candidates.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + candidates.dedup_by(|a, b| a.2 == b.2); + candidates + .into_iter() + .take(limit) + .map(|(_, _, name)| name) + .collect() +} + +fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> String { + let suggestions = suggest_tool_names(catalog, tool_name, 3); + if suggestions.is_empty() { + return format!( + "Tool '{tool_name}' is not available in the current tool catalog. \ + Verify mode/feature flags, or use {TOOL_SEARCH_BM25_NAME} with a short query." + ); + } + + format!( + "Tool '{tool_name}' is not available in the current tool catalog. \ + Did you mean: {}? You can also use {TOOL_SEARCH_BM25_NAME} to discover tools.", + suggestions.join(", ") + ) +} + +fn maybe_activate_requested_deferred_tool( + tool_name: &str, + catalog: &[Tool], + active_tools: &mut std::collections::HashSet, +) -> bool { + let Some(def) = catalog.iter().find(|def| def.name == tool_name) else { + return false; + }; + + if !def.defer_loading.unwrap_or(false) || active_tools.contains(tool_name) { + return false; + } + + active_tools.insert(tool_name.to_string()) +} + +fn execute_tool_search( + tool_name: &str, + input: &serde_json::Value, + catalog: &[Tool], + active_tools: &mut std::collections::HashSet, +) -> Result { + let query = required_str(input, "query")?; + let discovered = if tool_name == TOOL_SEARCH_REGEX_NAME { + discover_tools_with_regex(catalog, query)? + } else { + discover_tools_with_bm25_like(catalog, query) + }; + + for name in &discovered { + active_tools.insert(name.clone()); + } + + let references = discovered + .iter() + .map(|name| json!({"type": "tool_reference", "tool_name": name})) + .collect::>(); + + let payload = json!({ + "type": "tool_search_tool_search_result", + "tool_references": references, + }); + + Ok(ToolResult { + content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), + success: true, + metadata: Some(json!({ + "tool_references": discovered, + })), + }) +} + +async fn execute_code_execution_tool( + input: &serde_json::Value, + workspace: &std::path::Path, +) -> Result { + let code = required_str(input, "code")?; + let mut cmd = tokio::process::Command::new("python3"); + cmd.arg("-c"); + cmd.arg(code); + cmd.current_dir(workspace); + + let output = tokio::time::timeout(Duration::from_secs(120), cmd.output()) + .await + .map_err(|_| ToolError::Timeout { seconds: 120 }) + .and_then(|res| res.map_err(|e| ToolError::execution_failed(e.to_string())))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let return_code = output.status.code().unwrap_or(-1); + let success = output.status.success(); + let payload = json!({ + "type": "code_execution_result", + "stdout": stdout, + "stderr": stderr, + "return_code": return_code, + "content": [], + }); + + Ok(ToolResult { + content: serde_json::to_string(&payload).unwrap_or_else(|_| payload.to_string()), + success, + metadata: Some(payload), + }) +} + +fn caller_type_for_tool_use(caller: Option<&ToolCaller>) -> &str { + caller.map_or("direct", |c| c.caller_type.as_str()) +} + +/// #136: derive the file path(s) edited by a tool call. Returns the empty +/// vec for tools that don't modify files. We intentionally only handle the +/// three known edit tools — adding more (e.g. specialized refactor tools) +/// is a one-line change here. +fn edited_paths_for_tool(tool_name: &str, input: &serde_json::Value) -> Vec { + match tool_name { + "edit_file" | "write_file" => { + if let Some(path) = input.get("path").and_then(|v| v.as_str()) { + vec![PathBuf::from(path)] + } else { + Vec::new() + } + } + "apply_patch" => { + // `apply_patch` accepts either a `path` override or a list of + // `files` (each `{path, content}`). We try both shapes. + let mut out = Vec::new(); + if let Some(path) = input.get("path").and_then(|v| v.as_str()) { + out.push(PathBuf::from(path)); + } + if let Some(files) = input.get("files").and_then(|v| v.as_array()) { + for entry in files { + if let Some(path) = entry.get("path").and_then(|v| v.as_str()) { + out.push(PathBuf::from(path)); + } + } + } + // Fallback: parse `---`/`+++` headers from a unified diff payload. + if out.is_empty() + && let Some(patch) = input.get("patch").and_then(|v| v.as_str()) + { + out.extend(parse_patch_paths(patch)); + } + out + } + _ => Vec::new(), + } +} + +/// Lightweight parser for `+++ b/` lines in a unified diff. Used as a +/// fallback when `apply_patch` is invoked with raw `patch` text and no +/// `path`/`files` override. We deliberately keep this dumb — the real +/// `apply_patch` tool already validates the patch shape; we only need a +/// best-effort hint for the LSP hook. +fn parse_patch_paths(patch: &str) -> Vec { + let mut out = Vec::new(); + for line in patch.lines() { + if let Some(rest) = line.strip_prefix("+++ ") { + let trimmed = rest.trim(); + // Strip leading `b/` per git diff conventions. + let path = trimmed.strip_prefix("b/").unwrap_or(trimmed); + // Skip `/dev/null` (deletion). + if path == "/dev/null" { + continue; + } + out.push(PathBuf::from(path)); + } + } + out +} + +fn caller_allowed_for_tool(caller: Option<&ToolCaller>, tool_def: Option<&Tool>) -> bool { + let requested = caller_type_for_tool_use(caller); + if let Some(def) = tool_def + && let Some(allowed) = &def.allowed_callers + { + if allowed.is_empty() { + return requested == "direct"; + } + return allowed.iter().any(|item| item == requested); + } + requested == "direct" +} + +fn format_tool_error(err: &ToolError, tool_name: &str) -> String { + match err { + ToolError::InvalidInput { message } => { + format!("Invalid input for tool '{tool_name}': {message}") + } + ToolError::MissingField { field } => { + format!("Tool '{tool_name}' is missing required field '{field}'") + } + ToolError::PathEscape { path } => format!( + "Path escapes workspace: {}. Use a workspace-relative path or enable trust mode.", + path.display() + ), + ToolError::ExecutionFailed { message } => message.clone(), + ToolError::Timeout { seconds } => format!( + "Tool '{tool_name}' timed out after {seconds}s. Try a narrower scope or a longer timeout." + ), + ToolError::NotAvailable { message } => { + let lower = message.to_ascii_lowercase(); + if lower.contains("current tool catalog") || lower.contains("did you mean:") { + message.clone() + } else { + format!( + "Tool '{tool_name}' is not available: {message}. Check mode, feature flags, or tool name." + ) + } + } + ToolError::PermissionDenied { message } => format!( + "Tool '{tool_name}' was denied: {message}. Adjust approval mode or request permission." + ), + } +} + +fn summarize_text(text: &str, limit: usize) -> String { + if text.chars().count() <= limit { + return text.to_string(); + } + let take = limit.saturating_sub(3); + let mut out: String = text.chars().take(take).collect(); + out.push_str("..."); + out +} + +fn summarize_text_head_tail(text: &str, limit: usize) -> String { + let total = text.chars().count(); + if total <= limit { + return text.to_string(); + } + if limit <= 20 { + return summarize_text(text, limit); + } + + let marker = "\n\n[... output truncated for context ...]\n\n"; + let marker_len = marker.chars().count(); + if limit <= marker_len + 20 { + return summarize_text(text, limit); + } + + let remaining = limit - marker_len; + let head_len = remaining.saturating_mul(2) / 3; + let tail_len = remaining.saturating_sub(head_len); + let head: String = text.chars().take(head_len).collect(); + let tail_vec: Vec = text.chars().rev().take(tail_len).collect(); + let tail: String = tail_vec.into_iter().rev().collect(); + format!("{head}{marker}{tail}") +} + +fn tool_result_is_noisy(tool_name: &str) -> bool { + matches!( + tool_name, + "exec_shell" + | "exec_shell_wait" + | "exec_shell_interact" + | "multi_tool_use.parallel" + | "web_search" + ) +} + +fn tool_result_metadata_summary(metadata: Option<&serde_json::Value>) -> Option { + let obj = metadata?.as_object()?; + for key in ["summary", "stdout_summary", "stderr_summary", "message"] { + if let Some(text) = obj.get(key).and_then(serde_json::Value::as_str) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(summarize_text(trimmed, TOOL_RESULT_METADATA_SUMMARY_CHARS)); + } + } + } + None +} + +#[derive(Debug, Clone, Copy)] +struct ToolResultContextLimits { + hard_limit_chars: usize, + noisy_soft_limit_chars: usize, + snippet_chars: usize, +} + +fn tool_result_context_limits_for_model(model: &str) -> ToolResultContextLimits { + let is_large_context = + context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS); + + if is_large_context { + ToolResultContextLimits { + hard_limit_chars: LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS, + noisy_soft_limit_chars: LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS, + snippet_chars: LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS, + } + } else { + ToolResultContextLimits { + hard_limit_chars: TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS, + noisy_soft_limit_chars: TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS, + snippet_chars: TOOL_RESULT_CONTEXT_SNIPPET_CHARS, + } + } +} + +pub(crate) fn compact_tool_result_for_context( + model: &str, + tool_name: &str, + output: &ToolResult, +) -> String { + let raw = output.content.trim(); + if raw.is_empty() { + return String::new(); + } + + let limits = tool_result_context_limits_for_model(model); + let raw_chars = raw.chars().count(); + let should_compact = raw_chars > limits.hard_limit_chars + || (tool_result_is_noisy(tool_name) && raw_chars > limits.noisy_soft_limit_chars); + if !should_compact { + return raw.to_string(); + } + + let snippet = summarize_text_head_tail(raw, limits.snippet_chars); + let omitted = raw_chars.saturating_sub(snippet.chars().count()); + let summary = tool_result_metadata_summary(output.metadata.as_ref()); + + if let Some(summary) = summary { + format!( + "[{tool_name} output compacted to protect context]\nSummary: {summary}\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)" + ) + } else { + format!( + "[{tool_name} output compacted to protect context]\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)" + ) + } +} + +fn extract_compaction_summary_prompt(prompt: Option) -> Option { + match prompt { + Some(SystemPrompt::Blocks(blocks)) => { + let summary_blocks: Vec<_> = blocks + .into_iter() + .filter(|block| block.text.contains(COMPACTION_SUMMARY_MARKER)) + .collect(); + if summary_blocks.is_empty() { + None + } else { + Some(SystemPrompt::Blocks(summary_blocks)) + } + } + Some(SystemPrompt::Text(text)) => { + if text.contains(COMPACTION_SUMMARY_MARKER) { + Some(SystemPrompt::Text(text)) + } else { + None + } + } + None => None, + } +} + +fn remove_working_set_summary(prompt: Option<&SystemPrompt>) -> Option { + match prompt { + Some(SystemPrompt::Blocks(blocks)) => { + let filtered: Vec = blocks + .iter() + .filter(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER)) + .cloned() + .collect(); + if filtered.is_empty() { + None + } else { + Some(SystemPrompt::Blocks(filtered)) + } + } + Some(SystemPrompt::Text(text)) => Some(SystemPrompt::Text(text.clone())), + None => None, + } +} + +fn append_working_set_summary( + prompt: Option, + working_set_summary: Option<&str>, +) -> Option { + let Some(summary) = working_set_summary.map(str::trim).filter(|s| !s.is_empty()) else { + return prompt; + }; + let working_set_block = SystemBlock { + block_type: "text".to_string(), + text: summary.to_string(), + cache_control: None, + }; + + match prompt { + Some(SystemPrompt::Text(text)) => Some(SystemPrompt::Blocks(vec![ + SystemBlock { + block_type: "text".to_string(), + text, + cache_control: None, + }, + working_set_block, + ])), + Some(SystemPrompt::Blocks(mut blocks)) => { + blocks.retain(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER)); + blocks.push(working_set_block); + Some(SystemPrompt::Blocks(blocks)) + } + None => Some(SystemPrompt::Blocks(vec![working_set_block])), + } +} + +fn estimate_text_tokens_conservative(text: &str) -> usize { + text.chars().count().div_ceil(3) +} + +fn estimate_system_tokens_conservative(system: Option<&SystemPrompt>) -> usize { + match system { + Some(SystemPrompt::Text(text)) => estimate_text_tokens_conservative(text), + Some(SystemPrompt::Blocks(blocks)) => blocks + .iter() + .map(|block| estimate_text_tokens_conservative(&block.text)) + .sum(), + None => 0, + } +} + +fn estimate_input_tokens_conservative( + messages: &[Message], + system: Option<&SystemPrompt>, +) -> usize { + let message_tokens = estimate_tokens(messages).saturating_mul(3).div_ceil(2); + let system_tokens = estimate_system_tokens_conservative(system); + let framing_overhead = messages.len().saturating_mul(12).saturating_add(48); + message_tokens + .saturating_add(system_tokens) + .saturating_add(framing_overhead) +} + +fn context_input_budget(model: &str, requested_output_tokens: u32) -> Option { + let window = usize::try_from(context_window_for_model(model)?).ok()?; + let output = usize::try_from(requested_output_tokens).ok()?; + window + .checked_sub(output) + .and_then(|v| v.checked_sub(CONTEXT_HEADROOM_TOKENS)) +} + +fn is_context_length_error_message(message: &str) -> bool { + crate::error_taxonomy::classify_error_message(message) == ErrorCategory::InvalidInput +} + +fn emit_tool_audit(event: serde_json::Value) { + let Some(path) = std::env::var_os("DEEPSEEK_TOOL_AUDIT_LOG") else { + return; + }; + let line = match serde_json::to_string(&event) { + Ok(line) => line, + Err(_) => return, + }; + let path = PathBuf::from(path); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{line}"); + } +} + +impl Engine { + fn reset_cancel_token(&mut self) { + let token = CancellationToken::new(); + self.cancel_token = token.clone(); + match self.shared_cancel_token.lock() { + Ok(mut shared) => { + *shared = token; + } + Err(poisoned) => { + *poisoned.into_inner() = token; + } + } + } + + /// Create a new engine with the given configuration + pub fn new(config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) { + let (tx_op, rx_op) = mpsc::channel(32); + let (tx_event, rx_event) = mpsc::channel(256); + let (tx_approval, rx_approval) = mpsc::channel(64); + let (tx_user_input, rx_user_input) = mpsc::channel(32); + let (tx_steer, rx_steer) = mpsc::channel(64); + let cancel_token = CancellationToken::new(); + let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); + let tool_exec_lock = Arc::new(RwLock::new(())); + + // Create clients for both providers + let (deepseek_client, deepseek_client_error) = match DeepSeekClient::new(api_config) { + Ok(client) => (Some(client), None), + Err(err) => (None, Some(err.to_string())), + }; + + let mut session = Session::new( + config.model.clone(), + config.workspace.clone(), + config.allow_shell, + config.trust_mode, + config.notes_path.clone(), + config.mcp_config_path.clone(), + ); + + // Set up system prompt with project context (default to agent mode) + let working_set_summary = session.working_set.summary_block(&config.workspace); + let system_prompt = + prompts::system_prompt_for_mode_with_context(AppMode::Agent, &config.workspace, None); + session.system_prompt = + append_working_set_summary(Some(system_prompt), working_set_summary.as_deref()); + + let subagent_manager = + new_shared_subagent_manager(config.workspace.clone(), config.max_subagents); + let shell_manager = new_shared_shell_manager(config.workspace.clone()); + let capacity_controller = CapacityController::new(config.capacity.clone()); + + // Create Flash seam manager for layered context (#159). Uses the same + // API credentials as the main client but targets the Flash model for + // cost-effective summarisation and cycle briefing work. + let seam_manager = deepseek_client.as_ref().map(|main_client| { + let seam_config = SeamConfig { + enabled: api_config.context.enabled.unwrap_or(true), + verbatim_window_turns: api_config.context.verbatim_window_turns.unwrap_or( + crate::seam_manager::VERBATIM_WINDOW_TURNS, + ), + l1_threshold: api_config.context.l1_threshold.unwrap_or( + crate::seam_manager::DEFAULT_L1_THRESHOLD, + ), + l2_threshold: api_config.context.l2_threshold.unwrap_or( + crate::seam_manager::DEFAULT_L2_THRESHOLD, + ), + l3_threshold: api_config.context.l3_threshold.unwrap_or( + crate::seam_manager::DEFAULT_L3_THRESHOLD, + ), + cycle_threshold: api_config.context.cycle_threshold.unwrap_or( + crate::seam_manager::DEFAULT_CYCLE_THRESHOLD, + ), + seam_model: api_config + .context + .seam_model + .clone() + .unwrap_or_else(|| crate::seam_manager::DEFAULT_SEAM_MODEL.to_string()), + }; + SeamManager::new(main_client.clone(), seam_config) + }); + + let lsp_manager = Arc::new(match config.lsp_config.clone() { + Some(cfg) => crate::lsp::LspManager::new(cfg, config.workspace.clone()), + None => crate::lsp::LspManager::disabled(), + }); + + let mut engine = Engine { + config, + deepseek_client, + deepseek_client_error, + session, + subagent_manager, + shell_manager, + mcp_pool: None, + rx_op, + rx_approval, + rx_user_input, + rx_steer, + tx_event, + cancel_token: cancel_token.clone(), + shared_cancel_token: shared_cancel_token.clone(), + tool_exec_lock, + capacity_controller, + seam_manager, + coherence_state: CoherenceState::default(), + turn_counter: 0, + lsp_manager, + pending_lsp_blocks: Vec::new(), + }; + engine.rehydrate_latest_canonical_state(); + pending_lsp_blocks: Vec::new(), + }; + engine.rehydrate_latest_canonical_state(); + + let handle = EngineHandle { + tx_op, + rx_event: Arc::new(RwLock::new(rx_event)), + cancel_token: shared_cancel_token, + tx_approval, + tx_user_input, + tx_steer, + }; + + (engine, handle) + } + + /// Run the engine event loop + #[allow(clippy::too_many_lines)] + pub async fn run(mut self) { + while let Some(op) = self.rx_op.recv().await { + match op { + Op::SendMessage { + content, + mode, + model, + reasoning_effort, + allow_shell, + trust_mode, + auto_approve, + } => { + self.handle_send_message( + content, + mode, + model, + reasoning_effort, + allow_shell, + trust_mode, + auto_approve, + ) + .await; + } + Op::CancelRequest => { + self.cancel_token.cancel(); + self.reset_cancel_token(); + } + Op::ApproveToolCall { id } => { + // Tool approval handling will be implemented in tools module + let _ = self + .tx_event + .send(Event::status(format!("Approved tool call: {id}"))) + .await; + } + Op::DenyToolCall { id } => { + let _ = self + .tx_event + .send(Event::status(format!("Denied tool call: {id}"))) + .await; + } + Op::SpawnSubAgent { prompt } => { + let Some(client) = self.deepseek_client.clone() else { + let message = self + .deepseek_client_error + .as_deref() + .map(|err| format!("Failed to spawn sub-agent: {err}")) + .unwrap_or_else(|| { + "Failed to spawn sub-agent: API client not configured".to_string() + }); + let _ = self + .tx_event + .send(Event::error(ErrorEnvelope::fatal(message))) + .await; + continue; + }; + + let runtime = SubAgentRuntime::new( + client, + self.session.model.clone(), + // Sub-agents don't inherit YOLO mode - use Agent mode defaults + self.build_tool_context(AppMode::Agent, self.session.auto_approve), + self.session.allow_shell, + Some(self.tx_event.clone()), + Arc::clone(&self.subagent_manager), + ) + .with_max_spawn_depth(self.config.max_spawn_depth); + + let result = { + let mut manager = self.subagent_manager.lock().await; + manager.spawn_background( + Arc::clone(&self.subagent_manager), + runtime, + SubAgentType::General, + prompt.clone(), + None, + ) + }; + + match result { + Ok(snapshot) => { + let _ = self + .tx_event + .send(Event::status(format!( + "Spawned sub-agent {}", + snapshot.agent_id + ))) + .await; + } + Err(err) => { + let _ = self + .tx_event + .send(Event::error(ErrorEnvelope::fatal(format!( + "Failed to spawn sub-agent: {err}" + )))) + .await; + } + } + } + Op::ListSubAgents => { + let agents = { + let mut manager = self.subagent_manager.lock().await; + manager.cleanup(Duration::from_secs(60 * 60)); + manager.list() + }; + let _ = self.tx_event.send(Event::AgentList { agents }).await; + } + Op::ChangeMode { mode } => { + let _ = self + .tx_event + .send(Event::status(format!("Mode changed to: {mode:?}"))) + .await; + } + Op::SetModel { model } => { + self.session.model = model; + self.config.model.clone_from(&self.session.model); + let _ = self + .tx_event + .send(Event::status(format!( + "Model set to: {}", + self.session.model + ))) + .await; + } + Op::SetCompaction { config } => { + let enabled = config.enabled; + self.config.compaction = config; + let _ = self + .tx_event + .send(Event::status(format!( + "Auto-compaction {}", + if enabled { "enabled" } else { "disabled" } + ))) + .await; + } + Op::SyncSession { + messages, + system_prompt, + model, + workspace, + } => { + self.session.messages = messages; + self.session.compaction_summary_prompt = + extract_compaction_summary_prompt(system_prompt.clone()); + self.session.system_prompt = system_prompt; + self.session.model = model; + self.session.workspace = workspace.clone(); + self.config.model.clone_from(&self.session.model); + self.config.workspace = workspace.clone(); + let ctx = crate::project_context::load_project_context_with_parents(&workspace); + self.session.project_context = if ctx.has_instructions() { + Some(ctx) + } else { + None + }; + self.session.rebuild_working_set(); + self.rehydrate_latest_canonical_state(); + self.emit_session_updated().await; + let _ = self + .tx_event + .send(Event::status("Session context synced".to_string())) + .await; + } + Op::CompactContext => { + self.handle_manual_compaction().await; + } + Op::Rlm { + content, + model, + child_model, + max_depth, + } => { + self.handle_rlm(content, model, child_model, max_depth) + .await; + } + Op::Shutdown => { + break; + } + } + } + } + + async fn emit_session_updated(&self) { + let _ = self + .tx_event + .send(Event::SessionUpdated { + messages: self.session.messages.clone(), + system_prompt: self.session.system_prompt.clone(), + model: self.session.model.clone(), + workspace: self.session.workspace.clone(), + }) + .await; + } + + async fn add_session_message(&mut self, message: Message) { + self.session.add_message(message); + self.emit_session_updated().await; + } + + /// #136: post-edit hook. Inspects the tool name + input, derives the + /// edited file path, and asks the LSP manager for diagnostics. The + /// rendered block is queued in `pending_lsp_blocks` and flushed to the + /// session message stream just before the next API request. Failure is + /// silent by design — a missing/crashing LSP server must never block + /// the agent. + async fn run_post_edit_lsp_hook(&mut self, tool_name: &str, tool_input: &serde_json::Value) { + if !self.lsp_manager.config().enabled { + return; + } + let paths = edited_paths_for_tool(tool_name, tool_input); + for path in paths { + let absolute = if path.is_absolute() { + path.clone() + } else { + self.session.workspace.join(&path) + }; + // Use a short edit-sequence based on the existing turn counter so + // log output stays correlated even though we do not currently + // batch by sequence. + let seq = self.turn_counter; + if let Some(block) = self.lsp_manager.diagnostics_for(&absolute, seq).await { + self.pending_lsp_blocks.push(block); + } + } + } + + /// Drain `pending_lsp_blocks` into a single synthetic user message so the + /// model sees the diagnostics on its next request. Skips when nothing is + /// pending. The message uses the standard `text` content block shape + /// (the same shape as the post-tool steer messages) so we don't need to + /// invent a new envelope. + async fn flush_pending_lsp_diagnostics(&mut self) { + if self.pending_lsp_blocks.is_empty() { + return; + } + let blocks = std::mem::take(&mut self.pending_lsp_blocks); + let rendered = crate::lsp::render_blocks(&blocks); + if rendered.is_empty() { + return; + } + self.add_session_message(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: rendered, + cache_control: None, + }], + }) + .await; + } + + /// Handle a send message operation + #[allow(clippy::too_many_arguments)] + async fn handle_send_message( + &mut self, + content: String, + mode: AppMode, + model: String, + reasoning_effort: Option, + allow_shell: bool, + trust_mode: bool, + auto_approve: bool, + ) { + // Reset cancel token for fresh turn (in case previous was cancelled) + self.reset_cancel_token(); + + // Drain stale steer messages from previous turns. + while self.rx_steer.try_recv().is_ok() {} + + // Create turn context first so start event includes a stable turn id. + let mut turn = TurnContext::new(self.config.max_steps); + self.turn_counter = self.turn_counter.saturating_add(1); + self.capacity_controller.mark_turn_start(self.turn_counter); + + // Snapshot the workspace BEFORE we touch a single tool. Run the git + // work on the blocking pool so the async runtime stays responsive; + // failure is non-fatal (the helper logs at WARN). + if self.config.snapshots_enabled { + let pre_workspace = self.session.workspace.clone(); + let pre_seq = self.turn_counter; + let _ = tokio::task::spawn_blocking(move || pre_turn_snapshot(&pre_workspace, pre_seq)) + .await; + } + + // Emit turn started event + let _ = self + .tx_event + .send(Event::TurnStarted { + turn_id: turn.id.clone(), + }) + .await; + + // Check if we have the appropriate client + if self.deepseek_client.is_none() { + let message = self + .deepseek_client_error + .as_deref() + .map(|err| format!("Failed to send message: {err}")) + .unwrap_or_else(|| "Failed to send message: API client not configured".to_string()); + let _ = self + .tx_event + .send(Event::error(ErrorEnvelope::fatal_auth(message.clone()))) + .await; + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: turn.usage.clone(), + status: TurnOutcomeStatus::Failed, + error: Some(message), + }) + .await; + return; + } + + self.session + .working_set + .observe_user_message(&content, &self.session.workspace); + let force_update_plan_first = should_force_update_plan_first(mode, &content); + + // Add user message to session + let user_msg = Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: content, + cache_control: None, + }], + }; + self.session.add_message(user_msg); + + self.session.model = model; + self.config.model.clone_from(&self.session.model); + self.session.reasoning_effort = reasoning_effort; + self.session.allow_shell = allow_shell; + self.config.allow_shell = allow_shell; + self.session.trust_mode = trust_mode; + self.config.trust_mode = trust_mode; + self.session.auto_approve = auto_approve; + + // Update system prompt to match current mode and include persisted compaction context. + self.refresh_system_prompt(mode); + self.emit_session_updated().await; + + // Build tool registry and tool list for the current mode + let todo_list = self.config.todos.clone(); + let plan_state = self.config.plan_state.clone(); + + let tool_context = self.build_tool_context(mode, auto_approve); + let mut builder = if mode == AppMode::Plan { + ToolRegistryBuilder::new() + .with_read_only_file_tools() + .with_search_tools() + .with_git_tools() + .with_git_history_tools() + .with_diagnostics_tool() + .with_validation_tools() + .with_todo_tool(todo_list.clone()) + .with_plan_tool(plan_state.clone()) + } else { + ToolRegistryBuilder::new() + .with_agent_tools(self.session.allow_shell) + .with_todo_tool(todo_list.clone()) + .with_plan_tool(plan_state.clone()) + }; + + builder = builder + .with_review_tool(self.deepseek_client.clone(), self.session.model.clone()) + .with_rlm_tool(self.deepseek_client.clone(), self.session.model.clone()) + .with_user_input_tool() + .with_parallel_tool(); + + if self.config.features.enabled(Feature::ApplyPatch) && mode != AppMode::Plan { + builder = builder.with_patch_tools(); + } + if self.config.features.enabled(Feature::WebSearch) { + builder = builder.with_web_tools(); + } + // Plan mode now keeps shell available — the existing approval flow + // and command-safety classifier gate destructive commands. Writes + // and patches stay blocked above; that's the only "destructive" + // boundary plan mode enforces by tool registration. + if self.config.features.enabled(Feature::ShellTool) && self.session.allow_shell { + builder = builder.with_shell_tools(); + } + + // Mailbox for structured sub-agent envelopes (#128/#130). One per + // turn: the receiver is drained by a short-lived task that converts + // envelopes into `Event::SubAgentMailbox` so the UI can route them + // to the matching in-transcript card. The drainer exits naturally + // when every cloned sender is dropped at turn-end. + let mailbox_for_runtime = if self.config.features.enabled(Feature::Subagents) { + let cancel_token = self.cancel_token.child_token(); + let (mailbox, mut receiver) = Mailbox::new(cancel_token.clone()); + let tx_event_clone = self.tx_event.clone(); + tokio::spawn(async move { + while let Some(envelope) = receiver.recv().await { + if tx_event_clone + .send(Event::SubAgentMailbox { + seq: envelope.seq, + message: envelope.message, + }) + .await + .is_err() + { + break; + } + } + }); + Some((mailbox, cancel_token)) + } else { + None + }; + + let tool_registry = match mode { + AppMode::Agent | AppMode::Yolo => { + if self.config.features.enabled(Feature::Subagents) { + let runtime = if let Some(client) = self.deepseek_client.clone() { + let mut rt = SubAgentRuntime::new( + client, + self.session.model.clone(), + tool_context.clone(), + self.session.allow_shell, + Some(self.tx_event.clone()), + Arc::clone(&self.subagent_manager), + ) + .with_max_spawn_depth(self.config.max_spawn_depth); + if let Some((mailbox, cancel_token)) = mailbox_for_runtime.as_ref() { + rt = rt + .with_mailbox(mailbox.clone()) + .with_cancel_token(cancel_token.clone()); + } + Some(rt) + } else { + None + }; + Some( + builder + .with_subagent_tools( + self.subagent_manager.clone(), + runtime.expect("sub-agent runtime should exist with active client"), + ) + .build(tool_context), + ) + } else { + Some(builder.build(tool_context)) + } + } + _ => Some(builder.build(tool_context)), + }; + + let mcp_tools = if self.config.features.enabled(Feature::Mcp) { + self.mcp_tools().await + } else { + Vec::new() + }; + let tools = tool_registry.as_ref().map(|registry| { + let mut tools = registry.to_api_tools(); + for tool in &mut tools { + tool.defer_loading = Some(should_default_defer_tool(&tool.name, mode)); + } + let mut mcp_tools = mcp_tools; + for tool in &mut mcp_tools { + if mode == AppMode::Yolo { + tool.defer_loading = Some(false); + continue; + } + + let keep_loaded = matches!( + tool.name.as_str(), + "list_mcp_resources" + | "list_mcp_resource_templates" + | "mcp_read_resource" + | "read_mcp_resource" + | "mcp_get_prompt" + ); + tool.defer_loading = Some(!keep_loaded); + } + tools.extend(mcp_tools); + tools + }); + + // Main turn loop + let (status, error) = self + .handle_deepseek_turn( + &mut turn, + tool_registry.as_ref(), + tools, + mode, + force_update_plan_first, + ) + .await; + + // Update session usage + self.session.total_usage.add(&turn.usage); + + // Emit turn complete event + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: turn.usage, + status, + error, + }) + .await; + + // Post-turn snapshot. Same non-blocking, non-fatal contract as + // the pre-turn hook above. + if self.config.snapshots_enabled { + let post_workspace = self.session.workspace.clone(); + let post_seq = self.turn_counter; + let _ = + tokio::task::spawn_blocking(move || post_turn_snapshot(&post_workspace, post_seq)) + .await; + } + + // Checkpoint-restart cycle boundary (issue #124). The turn just + // settled cleanly — no in-flight tools, no streaming, no pending + // approval — so this is the safe phase to swap the context if we've + // crossed the per-cycle token threshold. We only fire on a + // Completed turn; Failed/Interrupted turns leave the buffer alone + // so the user can retry without a forced reset. + if matches!(status, TurnOutcomeStatus::Completed) { + self.maybe_advance_cycle(mode).await; + } + } + + async fn handle_manual_compaction(&mut self) { + let id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]); + let zero_usage = Usage { + input_tokens: 0, + output_tokens: 0, + ..Usage::default() + }; + let Some(client) = self.deepseek_client.clone() else { + let message = "Manual compaction unavailable: API client not configured".to_string(); + self.emit_compaction_failed(id, false, message.clone()) + .await; + let _ = self + .tx_event + .send(Event::error(ErrorEnvelope::fatal_auth(message.clone()))) + .await; + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: zero_usage, + status: TurnOutcomeStatus::Failed, + error: Some(message), + }) + .await; + return; + }; + + let start_message = "Manual context compaction started".to_string(); + self.emit_compaction_started(id.clone(), false, start_message) + .await; + + let compaction_pins = self + .session + .working_set + .pinned_message_indices(&self.session.messages, &self.session.workspace); + let compaction_paths = self.session.working_set.top_paths(24); + let messages_before = self.session.messages.len(); + let mut turn_status = TurnOutcomeStatus::Completed; + let mut turn_error = None; + + match compact_messages_safe( + &client, + &self.session.messages, + &self.config.compaction, + Some(&self.session.workspace), + Some(&compaction_pins), + Some(&compaction_paths), + ) + .await + { + Ok(result) => { + if !result.messages.is_empty() || self.session.messages.is_empty() { + let messages_after = result.messages.len(); + self.session.messages = result.messages; + self.merge_compaction_summary(result.summary_prompt); + self.emit_session_updated().await; + let removed = messages_before.saturating_sub(messages_after); + let message = if result.retries_used > 0 { + format!( + "Compaction complete: {messages_before} → {messages_after} messages ({removed} removed, {} retries)", + result.retries_used + ) + } else { + format!( + "Compaction complete: {messages_before} → {messages_after} messages ({removed} removed)" + ) + }; + self.emit_compaction_completed( + id, + false, + message, + Some(messages_before), + Some(messages_after), + ) + .await; + } else { + let message = "Compaction skipped: produced empty result".to_string(); + self.emit_compaction_failed(id, false, message.clone()) + .await; + turn_status = TurnOutcomeStatus::Failed; + turn_error = Some(message); + } + } + Err(err) => { + let message = format!("Manual context compaction failed: {err}"); + self.emit_compaction_failed(id, false, message.clone()) + .await; + let _ = self.tx_event.send(Event::status(message.clone())).await; + turn_status = TurnOutcomeStatus::Failed; + turn_error = Some(message); + } + } + + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: zero_usage, + status: turn_status, + error: turn_error, + }) + .await; + } + + /// Handle a Recursive Language Model (RLM) query — Algorithm 1 from + /// Zhang et al. (arXiv:2512.24601). + /// + /// The prompt is stored as PROMPT in a REPL variable. The root LLM + /// only sees metadata about the REPL state, never the prompt text + /// directly. The model generates Python code, which is executed by + /// the REPL. When FINAL() is called, the loop ends. + async fn handle_rlm( + &mut self, + content: String, + model: String, + child_model: String, + max_depth: u32, + ) { + use crate::rlm::turn::run_rlm_turn; + + let Some(ref client) = self.deepseek_client else { + let err = self + .deepseek_client_error + .as_deref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "API client not configured".to_string()); + let _ = self + .tx_event + .send(Event::error(ErrorEnvelope::fatal_auth(format!( + "RLM error: {err}" + )))) + .await; + return; + }; + + let _ = self + .tx_event + .send(Event::status("RLM turn started".to_string())) + .await; + + let result = run_rlm_turn( + client, + model, + content, + child_model, + self.tx_event.clone(), + max_depth, + ) + .await; + + let has_error = result.error.is_some(); + if let Some(ref err) = result.error { + let _ = self + .tx_event + .send(Event::error(ErrorEnvelope::tool(format!( + "RLM error: {err}" + )))) + .await; + } + + if !result.answer.is_empty() { + // Add the final answer as an assistant message in the session. + self.add_session_message(crate::models::Message { + role: "assistant".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: result.answer.clone(), + cache_control: None, + }], + }) + .await; + + let _ = self + .tx_event + .send(Event::MessageDelta { + index: 0, + content: result.answer.clone(), + }) + .await; + let _ = self + .tx_event + .send(Event::MessageComplete { index: 0 }) + .await; + } + + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: result.usage, + status: if has_error { + crate::core::events::TurnOutcomeStatus::Failed + } else { + crate::core::events::TurnOutcomeStatus::Completed + }, + error: result.error, + }) + .await; + } + + fn estimated_input_tokens(&self) -> usize { + estimate_input_tokens_conservative( + &self.session.messages, + self.session.system_prompt.as_ref(), + ) + } + + fn trim_oldest_messages_to_budget(&mut self, target_input_budget: usize) -> usize { + let mut removed = 0usize; + while self.session.messages.len() > MIN_RECENT_MESSAGES_TO_KEEP + && self.estimated_input_tokens() > target_input_budget + { + self.session.messages.remove(0); + removed = removed.saturating_add(1); + } + removed + } + + async fn recover_context_overflow( + &mut self, + client: &DeepSeekClient, + reason: &str, + requested_output_tokens: u32, + ) -> bool { + let Some(target_budget) = + context_input_budget(&self.session.model, requested_output_tokens) + else { + return false; + }; + + let id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]); + let start_message = format!("Emergency context compaction started ({reason})"); + self.emit_compaction_started(id.clone(), true, start_message) + .await; + + let before_tokens = self.estimated_input_tokens(); + let before_count = self.session.messages.len(); + + let mut retries_used = 0u32; + let mut summary_prompt = None; + let mut compacted_messages = self.session.messages.clone(); + + let mut forced_config = self.config.compaction.clone(); + forced_config.enabled = true; + forced_config.token_threshold = forced_config + .token_threshold + .min(target_budget.saturating_sub(1)) + .max(1); + forced_config.message_threshold = forced_config.message_threshold.max(1); + + match compact_messages_safe( + client, + &self.session.messages, + &forced_config, + Some(&self.session.workspace), + None, + None, + ) + .await + { + Ok(result) => { + retries_used = result.retries_used; + compacted_messages = result.messages; + summary_prompt = result.summary_prompt; + } + Err(err) => { + let _ = self + .tx_event + .send(Event::status(format!( + "Emergency compaction API pass failed: {err}. Falling back to local trim." + ))) + .await; + } + } + + if !compacted_messages.is_empty() || self.session.messages.is_empty() { + self.session.messages = compacted_messages; + } + self.merge_compaction_summary(summary_prompt); + + let trimmed = self.trim_oldest_messages_to_budget(target_budget); + self.emit_session_updated().await; + let after_tokens = self.estimated_input_tokens(); + let after_count = self.session.messages.len(); + let recovered = after_tokens <= target_budget + && (after_tokens < before_tokens || after_count < before_count || trimmed > 0); + + if recovered { + let removed = before_count.saturating_sub(after_count); + let mut details = format!( + "Emergency compaction complete: {before_count} → {after_count} messages ({removed} removed), ~{before_tokens} → ~{after_tokens} tokens" + ); + if retries_used > 0 { + details.push_str(&format!(" ({} retries)", retries_used)); + } + if trimmed > 0 { + details.push_str(&format!(", trimmed {trimmed} oldest")); + } + self.emit_compaction_completed( + id, + true, + details.clone(), + Some(before_count), + Some(after_count), + ) + .await; + let _ = self.tx_event.send(Event::status(details)).await; + return true; + } + + let message = format!( + "Emergency context compaction failed to reduce request below model limit \ + (estimate ~{} tokens, budget ~{}).", + after_tokens, target_budget + ); + self.emit_compaction_failed(id, true, message.clone()).await; + let _ = self.tx_event.send(Event::status(message)).await; + false + } + + fn build_tool_context(&self, mode: AppMode, auto_approve: bool) -> ToolContext { + // Load the per-workspace trusted-paths list (#29) on every tool-context + // build. Cheap (a small JSON file) and always reflects the latest + // `/trust add` / `/trust remove` mutations without an explicit cache + // refresh hook. + let trusted = crate::workspace_trust::WorkspaceTrust::load_for(&self.session.workspace); + let mut ctx = ToolContext::with_auto_approve( + self.session.workspace.clone(), + self.session.trust_mode, + self.session.notes_path.clone(), + self.session.mcp_config_path.clone(), + mode == AppMode::Yolo || auto_approve, + ) + .with_state_namespace(self.session.id.clone()) + .with_features(self.config.features.clone()) + .with_shell_manager(self.shell_manager.clone()) + .with_trusted_external_paths(trusted.paths().to_vec()); + + if let Some(decider) = self.config.network_policy.as_ref() { + ctx = ctx.with_network_policy(decider.clone()); + } + + if mode == AppMode::Yolo { + ctx.with_elevated_sandbox_policy(crate::sandbox::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![self.session.workspace.clone()], + network_access: true, + exclude_tmpdir: false, + exclude_slash_tmp: false, + }) + } else { + ctx + } + } + + async fn ensure_mcp_pool(&mut self) -> Result>, ToolError> { + if let Some(pool) = self.mcp_pool.as_ref() { + return Ok(Arc::clone(pool)); + } + let mut pool = McpPool::from_config_path(&self.session.mcp_config_path) + .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?; + if let Some(decider) = self.config.network_policy.as_ref() { + pool = pool.with_network_policy(decider.clone()); + } + let pool = Arc::new(AsyncMutex::new(pool)); + self.mcp_pool = Some(Arc::clone(&pool)); + Ok(pool) + } + + async fn mcp_tools(&mut self) -> Vec { + let pool = match self.ensure_mcp_pool().await { + Ok(pool) => pool, + Err(err) => { + let _ = self.tx_event.send(Event::status(err.to_string())).await; + return Vec::new(); + } + }; + + let mut pool = pool.lock().await; + let errors = pool.connect_all().await; + for (server, err) in errors { + let _ = self + .tx_event + .send(Event::status(format!( + "Failed to connect MCP server '{server}': {err}" + ))) + .await; + } + + pool.to_api_tools() + } + + async fn execute_mcp_tool_with_pool( + pool: Arc>, + name: &str, + input: serde_json::Value, + ) -> Result { + let mut pool = pool.lock().await; + let result = pool + .call_tool(name, input) + .await + .map_err(|e| ToolError::execution_failed(format!("MCP tool failed: {e}")))?; + let content = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()); + Ok(ToolResult::success(content)) + } + + async fn execute_parallel_tool( + &mut self, + input: serde_json::Value, + tool_registry: Option<&crate::tools::ToolRegistry>, + tool_exec_lock: Arc>, + ) -> Result { + let calls = parse_parallel_tool_calls(&input)?; + let mcp_pool = if calls.iter().any(|(tool, _)| McpPool::is_mcp_tool(tool)) { + Some(self.ensure_mcp_pool().await?) + } else { + None + }; + let Some(registry) = tool_registry else { + return Err(ToolError::not_available( + "tool registry unavailable for multi_tool_use.parallel", + )); + }; + + let mut tasks = FuturesUnordered::new(); + for (tool_name, tool_input) in calls { + if tool_name == MULTI_TOOL_PARALLEL_NAME { + return Err(ToolError::invalid_input( + "multi_tool_use.parallel cannot call itself", + )); + } + if McpPool::is_mcp_tool(&tool_name) { + if !mcp_tool_is_parallel_safe(&tool_name) { + return Err(ToolError::invalid_input(format!( + "Tool '{tool_name}' is an MCP tool and cannot run in parallel. \ + Allowed MCP tools: list_mcp_resources, list_mcp_resource_templates, \ + mcp_read_resource, read_mcp_resource, mcp_get_prompt." + ))); + } + } else { + let Some(spec) = registry.get(&tool_name) else { + return Err(ToolError::not_available(format!( + "tool '{tool_name}' is not registered" + ))); + }; + if !spec.is_read_only() { + return Err(ToolError::invalid_input(format!( + "Tool '{tool_name}' is not read-only and cannot run in parallel" + ))); + } + if spec.approval_requirement() != ApprovalRequirement::Auto { + return Err(ToolError::invalid_input(format!( + "Tool '{tool_name}' requires approval and cannot run in parallel" + ))); + } + if !spec.supports_parallel() { + return Err(ToolError::invalid_input(format!( + "Tool '{tool_name}' does not support parallel execution" + ))); + } + } + + let registry_ref = registry; + let lock = tool_exec_lock.clone(); + let tx_event = self.tx_event.clone(); + let mcp_pool = mcp_pool.clone(); + tasks.push(async move { + let result = Engine::execute_tool_with_lock( + lock, + true, + false, + tx_event, + tool_name.clone(), + tool_input.clone(), + Some(registry_ref), + mcp_pool, + None, + ) + .await; + (tool_name, result) + }); + } + + let mut results = Vec::new(); + while let Some((tool_name, result)) = tasks.next().await { + match result { + Ok(output) => { + let mut error = None; + if !output.success { + error = Some(output.content.clone()); + } + results.push(ParallelToolResultEntry { + tool_name, + success: output.success, + content: output.content, + error, + }); + } + Err(err) => { + let message = format!("{err}"); + results.push(ParallelToolResultEntry { + tool_name, + success: false, + content: format!("Error: {message}"), + error: Some(message), + }); + } + } + } + + ToolResult::json(&ParallelToolResult { results }) + .map_err(|e| ToolError::execution_failed(e.to_string())) + } + + #[allow(clippy::too_many_arguments)] + async fn execute_tool_with_lock( + lock: Arc>, + supports_parallel: bool, + interactive: bool, + tx_event: mpsc::Sender, + tool_name: String, + tool_input: serde_json::Value, + registry: Option<&crate::tools::ToolRegistry>, + mcp_pool: Option>>, + context_override: Option, + ) -> Result { + let _guard = if supports_parallel { + ToolExecGuard::Read(lock.read().await) + } else { + ToolExecGuard::Write(lock.write().await) + }; + + if interactive { + let _ = tx_event.send(Event::PauseEvents).await; + } + + let result = if McpPool::is_mcp_tool(&tool_name) { + if let Some(pool) = mcp_pool { + Engine::execute_mcp_tool_with_pool(pool, &tool_name, tool_input).await + } else { + Err(ToolError::not_available(format!( + "tool '{tool_name}' is not registered" + ))) + } + } else if let Some(registry) = registry { + registry + .execute_full_with_context(&tool_name, tool_input, context_override.as_ref()) + .await + } else { + Err(ToolError::not_available(format!( + "tool '{tool_name}' is not registered" + ))) + }; + + if interactive { + let _ = tx_event.send(Event::ResumeEvents).await; + } + + result + } + + /// Handle a turn using the DeepSeek API. + #[allow(clippy::too_many_lines)] + /// Run the pre-request layered-context checkpoint (#159). Checks whether + /// cumulative tokens have crossed a soft-seam threshold and, if so, + /// produces an `` block via Flash and appends it as an + /// assistant message. Called from `handle_deepseek_turn` before each API + /// request so the model always has the latest navigation aids. + async fn layered_context_checkpoint(&mut self) { + let Some(ref seam_mgr) = self.seam_manager else { + return; + }; + if !seam_mgr.config().enabled { + return; + } + + // Cumulative tokens: session total (all turns so far) + current + // estimated input (the messages that will be sent next). + let cumulative_input = self + .session + .total_usage + .input_tokens + .saturating_add(self.session.total_usage.output_tokens); + let cumulative_estimate = + cumulative_input.saturating_add(self.estimated_input_tokens() as u64); + + let highest = seam_mgr.highest_level().await; + let Some(level) = seam_mgr.seam_level_for(cumulative_estimate as usize, highest) else { + return; + }; + + // Determine the message range to summarize: everything before the + // verbatim window. The verbatim window (last ~16 turns) stays + // untouched so the model always has ground-truth recent context. + let msg_count = self.session.messages.len(); + let verbatim_start = seam_mgr.verbatim_window_start(msg_count); + if verbatim_start == 0 { + return; // Not enough messages to summarize. + } + + let msg_range_end = verbatim_start; + let pinned = self + .session + .working_set + .pinned_message_indices(&self.session.messages, &self.session.workspace); + + let _ = self + .tx_event + .send(Event::status(format!( + "⏻ producing L{level} context seam ({msg_range_end} messages)…" + ))) + .await; + + // If we have existing seams, recompact; otherwise produce fresh. + let existing_seams = seam_mgr.collect_seam_texts(&self.session.messages).await; + let seam_text = if existing_seams.is_empty() { + match seam_mgr + .produce_soft_seam( + &self.session.messages, + level, + 0, + msg_range_end, + Some(&self.session.workspace), + &pinned, + ) + .await + { + Ok(text) => text, + Err(err) => { + crate::logging::warn(format!("L{level} soft seam failed: {err}")); + return; + } + } + } else { + let recent: Vec<&Message> = (0..msg_range_end) + .filter_map(|i| self.session.messages.get(i)) + .collect(); + match seam_mgr + .recompact(&existing_seams, &recent, level, 0, msg_range_end) + .await + { + Ok(text) => text, + Err(err) => { + crate::logging::warn(format!("L{level} recompact failed: {err}")); + return; + } + } + }; + + if seam_text.is_empty() { + return; + } + + // Append the seam as an assistant message. This is an append-only + // operation — no messages are deleted. The prefix cache stays hot. + self.add_session_message(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: seam_text, + cache_control: None, + }], + }) + .await; + + let seam_count = seam_mgr.seam_count().await; + let _ = self + .tx_event + .send(Event::status(format!( + "⏻ L{level} seam complete ({seam_count} total, {msg_range_end} messages covered)" + ))) + .await; + } + + /// Run the checkpoint-restart cycle boundary if the session has crossed + /// its token threshold (issue #124). No-op in the common case. + /// + /// Caller must invoke this only at a clean turn boundary (no in-flight + /// tool, no open stream, no pending approval modal). The phase guard + /// inside `should_advance_cycle` is a defence-in-depth check; the + /// engine's wider state machine is the primary enforcement layer. + /// + /// Sub-agents are intentionally NOT awaited: each sub-agent has its own + /// context, the parent's reset doesn't invalidate them. Their handles + /// are captured in the structured-state block so the next cycle can see + /// they're still running. + async fn maybe_advance_cycle(&mut self, mode: AppMode) { + if !should_advance_cycle( + self.session.total_usage.input_tokens, + self.session.total_usage.output_tokens, + &self.session.model, + &self.config.cycle, + false, + ) { + return; + } + + let Some(client) = self.deepseek_client.clone() else { + crate::logging::warn( + "Cycle boundary skipped: API client not configured for briefing turn", + ); + return; + }; + + let from = self.session.cycle_count; + let to = from.saturating_add(1); + let archive_started = self.session.current_cycle_started; + let max_briefing_tokens = self.config.cycle.briefing_max_for(&self.session.model); + + let _ = self + .tx_event + .send(Event::status(format!( + "↻ context refreshing (cycle {from} → {to}, generating briefing…)" + ))) + .await; + + // 1. Generate the model-curated briefing. Prefer the Flash seam + // manager (#159) for cost and speed; fall back to the main model + // (legacy produce_briefing) when the seam manager isn't available. + let briefing_text = if let Some(ref seam_mgr) = self.seam_manager { + let seams = seam_mgr.collect_seam_texts(&self.session.messages).await; + let state_text = { + let s = StructuredState::capture( + mode.label(), + self.config.workspace.clone(), + std::env::current_dir().ok(), + &self.session.working_set, + &self.config.todos, + &self.config.plan_state, + Some(&self.subagent_manager), + ) + .await; + s.to_system_block() + }; + match seam_mgr + .produce_flash_briefing(&seams, state_text.as_deref()) + .await + { + Ok(text) => text, + Err(err) => { + crate::logging::warn(format!( + "Flash briefing failed, falling back to main model: {err}" + )); + match produce_briefing( + &client, + &self.session.model, + &self.session.messages, + max_briefing_tokens, + ) + .await + { + Ok(text) => text, + Err(err2) => { + crate::logging::warn(format!( + "Cycle briefing turn failed; skipping cycle advance: {err2}" + )); + let _ = self + .tx_event + .send(Event::status(format!( + "↻ cycle handoff failed (continuing in cycle {from}): {err2}" + ))) + .await; + return; + } + } + } + } + } else { + match produce_briefing( + &client, + &self.session.model, + &self.session.messages, + max_briefing_tokens, + ) + .await + { + Ok(text) => text, + Err(err) => { + crate::logging::warn(format!( + "Cycle briefing turn failed; skipping cycle advance: {err}" + )); + let _ = self + .tx_event + .send(Event::status(format!( + "↻ cycle handoff failed (continuing in cycle {from}): {err}" + ))) + .await; + return; + } + } + }; + + let briefing_tokens = estimate_briefing_tokens(&briefing_text); + let now = chrono::Utc::now(); + let briefing = CycleBriefing { + cycle: to, + timestamp: now, + briefing_text: briefing_text.clone(), + token_estimate: briefing_tokens, + }; + + // 2. Archive the cycle to disk. If the archive write fails we still + // proceed with the swap — the briefing alone preserves enough + // state to continue, and the user can recover the lost archive + // from their session log if needed. + match archive_cycle( + &self.session.id, + to, + &self.session.messages, + &self.session.model, + archive_started, + ) { + Ok(path) => { + crate::logging::info(format!("Cycle {to} archived to {}", path.display())); + } + Err(err) => { + crate::logging::warn(format!( + "Failed to archive cycle {to}; continuing with swap: {err}" + )); + } + } + + // 3. Capture structured state. Locks are held only for the snapshot. + let state = StructuredState::capture( + mode.label(), + self.config.workspace.clone(), + std::env::current_dir().ok(), + &self.session.working_set, + &self.config.todos, + &self.config.plan_state, + Some(&self.subagent_manager), + ) + .await; + let state_block = state.to_system_block(); + + // 4. Build the seed messages. The next cycle starts with the + // base system prompt (refreshed below) and these seeds. + let seed_messages = build_seed_messages( + state_block.as_deref(), + Some(&briefing), + None, // pending_user_message — pulled from steer/queue elsewhere + ); + + // 5. Atomic swap. + self.session.messages = seed_messages; + self.session.cycle_count = to; + self.session.current_cycle_started = now; + self.session.cycle_briefings.push(briefing.clone()); + // Reset seam tracking for the new cycle. + if let Some(ref seam_mgr) = self.seam_manager { + seam_mgr.reset().await; + } + // Drop any compaction summary — that path is incompatible with the + // fresh-context model and would Frankenstein-merge with the briefing. + self.session.compaction_summary_prompt = None; + self.refresh_system_prompt(mode); + self.emit_session_updated().await; + + let _ = self + .tx_event + .send(Event::CycleAdvanced { + from, + to, + briefing: briefing.clone(), + }) + .await; + let _ = self + .tx_event + .send(Event::status(format!( + "↻ context refreshed (cycle {from} → {to}, briefing: {briefing_tokens} tokens carried)" + ))) + .await; + } + + /// Refresh the system prompt based on current mode and context. + fn refresh_system_prompt(&mut self, mode: AppMode) { + let working_set_summary = self + .session + .working_set + .summary_block(&self.config.workspace); + let base = prompts::system_prompt_for_mode_with_context(mode, &self.config.workspace, None); + let stable_prompt = + merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone()); + self.session.system_prompt = + append_working_set_summary(stable_prompt, working_set_summary.as_deref()); + } + + fn merge_compaction_summary(&mut self, summary_prompt: Option) { + if summary_prompt.is_none() { + return; + } + self.session.compaction_summary_prompt = merge_system_prompts( + self.session.compaction_summary_prompt.as_ref(), + summary_prompt.clone(), + ); + let current_without_working_set = + remove_working_set_summary(self.session.system_prompt.as_ref()); + let merged = merge_system_prompts(current_without_working_set.as_ref(), summary_prompt); + let working_set_summary = self + .session + .working_set + .summary_block(&self.config.workspace); + self.session.system_prompt = + append_working_set_summary(merged, working_set_summary.as_deref()); + } +} + +/// Spawn the engine in a background task +pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle { + let (engine, handle) = Engine::new(config, api_config); + + tokio::spawn(async move { + engine.run().await; + }); + + handle +} + +#[cfg(test)] +pub(crate) struct MockEngineHandle { + pub handle: EngineHandle, + pub rx_op: mpsc::Receiver, + rx_approval: mpsc::Receiver, + pub rx_steer: mpsc::Receiver, + pub tx_event: mpsc::Sender, + pub cancel_token: CancellationToken, +} + +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MockApprovalEvent { + Approved { + id: String, + }, + Denied { + id: String, + }, + RetryWithPolicy { + id: String, + policy: crate::sandbox::SandboxPolicy, + }, +} + +#[cfg(test)] +impl MockEngineHandle { + pub(crate) async fn recv_approval_event(&mut self) -> Option { + match self.rx_approval.recv().await? { + ApprovalDecision::Approved { id } => Some(MockApprovalEvent::Approved { id }), + ApprovalDecision::Denied { id } => Some(MockApprovalEvent::Denied { id }), + ApprovalDecision::RetryWithPolicy { id, policy } => { + Some(MockApprovalEvent::RetryWithPolicy { id, policy }) + } + } + } +} + +#[cfg(test)] +pub(crate) fn mock_engine_handle() -> MockEngineHandle { + let (tx_op, rx_op) = mpsc::channel(32); + let (tx_event, rx_event) = mpsc::channel(256); + let (tx_approval, rx_approval) = mpsc::channel(64); + let (tx_user_input, _rx_user_input) = mpsc::channel(32); + let (tx_steer, rx_steer) = mpsc::channel(64); + let cancel_token = CancellationToken::new(); + let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); + let handle = EngineHandle { + tx_op, + rx_event: Arc::new(RwLock::new(rx_event)), + cancel_token: shared_cancel_token, + tx_approval, + tx_user_input, + tx_steer, + }; + + MockEngineHandle { + handle, + rx_op, + rx_approval, + rx_steer, + tx_event, + cancel_token, + } +} + +mod approval; +mod capacity_flow; +mod dispatch; +mod turn_loop; + +use self::approval::{ApprovalDecision, ApprovalResult, UserInputDecision}; +use self::dispatch::{ + ParallelToolResult, ParallelToolResultEntry, ToolExecGuard, ToolExecOutcome, ToolExecutionPlan, + final_tool_input, mcp_tool_approval_description, mcp_tool_is_parallel_safe, + mcp_tool_is_read_only, parse_parallel_tool_calls, parse_tool_input, + should_force_update_plan_first, should_parallelize_tool_batch, should_stop_after_plan_tool, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 48dbe380..a67af16c 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -212,6 +212,12 @@ impl Engine { // model sees compile errors before its next reasoning step. self.flush_pending_lsp_diagnostics().await; + // #159: layered context seam checkpoint. Produces soft seams at + // 192K/384K/576K via Flash and appends blocks + // so the model can navigate deep history without losing prefix + // cache affinity. + self.layered_context_checkpoint().await; + // Build the request let force_update_plan_this_step = force_update_plan_first && turn.tool_calls.is_empty(); let active_tools = if tool_catalog.is_empty() { diff --git a/crates/tui/src/cycle_manager.rs b/crates/tui/src/cycle_manager.rs index 3f868c38..27681ffb 100644 --- a/crates/tui/src/cycle_manager.rs +++ b/crates/tui/src/cycle_manager.rs @@ -28,13 +28,13 @@ //! //! ## Trigger //! -//! - Token threshold: **110K** by default (leaves ~8.5K headroom for the -//! briefing turn plus next-turn growth before crossing the 128K elbow). +//! - Token threshold: **768K** by default (~75% of the 1M window). Soft seams +//! at 192K/384K/576K (layered context manager, #159) handle intermediate +//! thresholds. The hard cycle only fires near the wall. //! - Phase guard: callers only invoke `should_advance_cycle` at clean turn //! boundaries (no in-flight tool, no streaming, no approval modal). //! - Per-model overrides: `[cycle.per_model]` in config.toml lets operators -//! tune the threshold separately for `deepseek-v4-pro` vs. `-flash` if -//! their workloads have different briefing costs. +//! tune the threshold separately for `deepseek-v4-pro` vs. `-flash`. use std::collections::HashMap; use std::fs::{File, OpenOptions}; @@ -56,10 +56,12 @@ use crate::working_set::WorkingSet; /// JSONL header record emitted as the first line of an archived cycle file. const CYCLE_ARCHIVE_SCHEMA_VERSION: u32 = 1; -/// Default token threshold at which a cycle boundary fires. Set below the V4 -/// 128K retrieval elbow to leave room for the briefing turn (≤3K tokens) plus -/// the next user turn before the next boundary. -pub const DEFAULT_CYCLE_THRESHOLD_TOKENS: usize = 110_000; +/// Default token threshold at which a cycle boundary fires. +/// +/// Bumped from 110K (pre-#159) to 768K (~75% of 1M window) in v0.7.2. +/// The layered context manager (#159) handles intermediate thresholds via +/// soft seams at 192K/384K/576K, so the hard cycle only fires near the wall. +pub const DEFAULT_CYCLE_THRESHOLD_TOKENS: usize = 768_000; /// Default cap on the model-curated briefing block. pub const DEFAULT_BRIEFING_MAX_TOKENS: usize = 3_000; @@ -758,10 +760,10 @@ mod tests { #[test] fn should_advance_combines_input_and_output() { let cfg = CycleConfig::default(); - // 60k + 60k = 120k > 110k threshold + // 400K + 400K = 800K > 768K threshold assert!(should_advance_cycle( - 60_000, - 60_000, + 400_000, + 400_000, "deepseek-v4-pro", &cfg, false diff --git a/crates/tui/src/cycle_manager.rs.bak3 b/crates/tui/src/cycle_manager.rs.bak3 new file mode 100644 index 00000000..8dd9f6c9 --- /dev/null +++ b/crates/tui/src/cycle_manager.rs.bak3 @@ -0,0 +1,1014 @@ +//! Checkpoint-restart cycle management for long-running sessions (issue #124). +//! +//! ## Why +//! +//! DeepSeek V4's empirical retrieval elbow is 128K tokens (paper Figure 9 — +//! 8K/0.90, 64K/0.87, 128K/0.85, 256K/0.76, 512K/0.66, 1M/0.59). Lossy +//! summarization compaction creates a "Frankenstein" context — half verbatim, +//! half paraphrased — that the model cannot tell apart, so it treats the +//! summary as if it were verbatim and confabulates around the gaps. +//! +//! Checkpoint-restart fixes this by giving every cycle a *homogeneous* fresh +//! context: original system prompt, structured state (todos / plan / working +//! set / sub-agent handles), and a model-curated free-form briefing of at +//! most ~3,000 tokens. The previous cycle is archived to disk in JSONL form +//! so a future `recall_archive` tool (issue #127) can search it on demand. +//! +//! ## Layers of carry-forward +//! +//! 1. **Auto-preserved** (deterministic, no agent judgment): the original +//! system prompt, `SharedTodoList`, `SharedPlanState`, working-set paths, +//! open sub-agent snapshots, mode / workspace / cwd, and the user's most +//! recent unsent message. +//! 2. **Free-form briefing** (model-curated, wrapped as ``): +//! decisions made + why, constraints discovered, hypotheses being tested, +//! approaches that failed, open questions. Tool output bytes, file +//! contents, and step-by-step recaps explicitly do NOT belong here — +//! they're either in the archive or recoverable from disk. +//! +//! ## Trigger +//! +//! - Token threshold: **768K** by default (~75% of the 1M window). Soft seams +//! at 192K/384K/576K (layered context manager, #159) handle intermediate +//! thresholds. The hard cycle only fires near the wall. +//! - Phase guard: callers only invoke `should_advance_cycle` at clean turn +//! boundaries (no in-flight tool, no streaming, no approval modal). +//! - Per-model overrides: `[cycle.per_model]` in config.toml lets operators +//! tune the threshold separately for `deepseek-v4-pro` vs. `-flash`. + +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::client::DeepSeekClient; +use crate::llm_client::LlmClient; +use crate::models::{ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt}; +use crate::tools::plan::{PlanSnapshot, SharedPlanState}; +use crate::tools::subagent::{SharedSubAgentManager, SubAgentResult, SubAgentStatus}; +use crate::tools::todo::{SharedTodoList, TodoListSnapshot}; +use crate::working_set::WorkingSet; + +/// JSONL header record emitted as the first line of an archived cycle file. +const CYCLE_ARCHIVE_SCHEMA_VERSION: u32 = 1; + +/// Default token threshold at which a cycle boundary fires. +/// +/// Bumped from 110K (pre-#159) to 768K (~75% of 1M window) in v0.7.2. +/// The layered context manager (#159) handles intermediate thresholds via +/// soft seams at 192K/384K/576K, so the hard cycle only fires near the wall. +/// Default token threshold at which a cycle boundary fires. +/// +/// Bumped from 110K (pre-#159) to 768K (~75% of 1M window) in v0.7.2. +/// The layered context manager (#159) handles intermediate thresholds via +/// soft seams at 192K/384K/576K, so the hard cycle only fires near the wall. +pub const DEFAULT_CYCLE_THRESHOLD_TOKENS: usize = 768_000; + +/// Default cap on the model-curated briefing block. +pub const DEFAULT_BRIEFING_MAX_TOKENS: usize = 3_000; + +/// Conservative chars-per-token used to bound the briefing length to the +/// configured token cap. Matches `compaction::estimate_tokens` (~4 chars/token). +const APPROX_CHARS_PER_TOKEN: usize = 4; + +/// Per-model cycle tuning. Loaded from `[cycle.per_model.]`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelCycleConfig { + /// Token threshold above which a cycle boundary fires. + pub threshold_tokens: usize, + /// Cap on the model-curated `` briefing. + pub briefing_max_tokens: usize, +} + +impl Default for ModelCycleConfig { + fn default() -> Self { + Self { + threshold_tokens: DEFAULT_CYCLE_THRESHOLD_TOKENS, + briefing_max_tokens: DEFAULT_BRIEFING_MAX_TOKENS, + } + } +} + +/// Top-level cycle configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CycleConfig { + /// Whether checkpoint-restart cycles are enabled. Defaults to true. + pub enabled: bool, + /// Default token threshold; per-model overrides take precedence when present. + pub threshold_tokens: usize, + /// Default briefing cap; per-model overrides take precedence when present. + pub briefing_max_tokens: usize, + /// Per-model overrides keyed by model identifier (e.g. `deepseek-v4-pro`). + pub per_model: HashMap, +} + +impl Default for CycleConfig { + fn default() -> Self { + let mut per_model: HashMap = HashMap::new(); + per_model.insert("deepseek-v4-pro".to_string(), ModelCycleConfig::default()); + per_model.insert("deepseek-v4-flash".to_string(), ModelCycleConfig::default()); + Self { + enabled: true, + threshold_tokens: DEFAULT_CYCLE_THRESHOLD_TOKENS, + briefing_max_tokens: DEFAULT_BRIEFING_MAX_TOKENS, + per_model, + } + } +} + +impl CycleConfig { + /// Resolve the threshold for a given model (per-model override > default). + #[must_use] + pub fn threshold_for(&self, model: &str) -> usize { + self.per_model + .get(model) + .map(|m| m.threshold_tokens) + .unwrap_or(self.threshold_tokens) + } + + /// Resolve the briefing-token cap for a given model. + #[must_use] + pub fn briefing_max_for(&self, model: &str) -> usize { + self.per_model + .get(model) + .map(|m| m.briefing_max_tokens) + .unwrap_or(self.briefing_max_tokens) + } +} + +/// Snapshot of a model-curated briefing produced at cycle handoff. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CycleBriefing { + /// 1-based cycle number this briefing closes (i.e. the cycle being archived). + pub cycle: u32, + /// UTC timestamp when the briefing turn completed. + pub timestamp: DateTime, + /// Extracted contents of the `` block. + pub briefing_text: String, + /// Approximate token count of `briefing_text`. + pub token_estimate: usize, +} + +/// Decide whether a cycle boundary should fire. +/// +/// `usage` is the *cumulative* session input+output tokens (both `u64` to +/// match `SessionUsage`). `in_flight` is true when a tool is mid-execution, +/// stream is open, or an approval modal is pending — in those cases the +/// caller must wait until the next clean boundary. +#[must_use] +pub fn should_advance_cycle( + cumulative_input_tokens: u64, + cumulative_output_tokens: u64, + model: &str, + cfg: &CycleConfig, + in_flight: bool, +) -> bool { + if !cfg.enabled || in_flight { + return false; + } + let total = cumulative_input_tokens.saturating_add(cumulative_output_tokens); + let threshold = cfg.threshold_for(model) as u64; + if threshold == 0 { + return false; + } + total >= threshold +} + +/// Roll-up of state that survives a cycle boundary deterministically. +/// +/// Construction is cheap — borrow the live state, snapshot it once, render it +/// into a system block. The snapshot decouples rendering from any mutex held +/// by the engine. +#[derive(Debug, Clone, Default)] +pub struct StructuredState { + pub mode_label: String, + pub workspace: PathBuf, + pub cwd: Option, + pub working_set_summary: Option, + pub todo_snapshot: Option, + pub plan_snapshot: Option, + pub subagent_snapshots: Vec, +} + +impl StructuredState { + /// Capture the current state. All locks are held only for the duration of + /// the snapshot. + pub async fn capture( + mode_label: impl Into, + workspace: PathBuf, + cwd: Option, + working_set: &WorkingSet, + todos: &SharedTodoList, + plan_state: &SharedPlanState, + subagents: Option<&SharedSubAgentManager>, + ) -> Self { + let working_set_summary = working_set.summary_block(&workspace); + + let todo_snapshot = { + let guard = todos.lock().await; + let snap = guard.snapshot(); + if snap.items.is_empty() { + None + } else { + Some(snap) + } + }; + + let plan_snapshot = { + let guard = plan_state.lock().await; + if guard.is_empty() { + None + } else { + Some(guard.snapshot()) + } + }; + + let subagent_snapshots = if let Some(handle) = subagents { + let guard = handle.lock().await; + guard + .list() + .into_iter() + .filter(|s| matches!(s.status, SubAgentStatus::Running)) + .collect() + } else { + Vec::new() + }; + + Self { + mode_label: mode_label.into(), + workspace, + cwd, + working_set_summary, + todo_snapshot, + plan_snapshot, + subagent_snapshots, + } + } + + /// Render the structured state as a single system block. Returns `None` + /// when there is nothing meaningful to carry forward (rare in practice — + /// at least the workspace and mode are always present). + #[must_use] + pub fn to_system_block(&self) -> Option { + let mut out = String::new(); + out.push_str("## Cycle State (Auto-Preserved)\n\n"); + out.push_str(&format!("- Mode: `{}`\n", self.mode_label)); + out.push_str(&format!("- Workspace: `{}`\n", self.workspace.display())); + if let Some(cwd) = self.cwd.as_ref() { + out.push_str(&format!("- Cwd: `{}`\n", cwd.display())); + } + + if let Some(plan) = self.plan_snapshot.as_ref() { + out.push_str("\n### Plan\n"); + if let Some(explanation) = plan.explanation.as_ref() { + out.push_str(&format!("{explanation}\n\n")); + } + for item in &plan.items { + let marker = match item.status { + crate::tools::plan::StepStatus::Pending => "[ ]", + crate::tools::plan::StepStatus::InProgress => "[~]", + crate::tools::plan::StepStatus::Completed => "[x]", + }; + out.push_str(&format!("- {marker} {}\n", item.step)); + } + } + + if let Some(todos) = self.todo_snapshot.as_ref() { + out.push_str(&format!( + "\n### Todos ({}% complete)\n", + todos.completion_pct + )); + for item in &todos.items { + let marker = match item.status { + crate::tools::todo::TodoStatus::Pending => "[ ]", + crate::tools::todo::TodoStatus::InProgress => "[~]", + crate::tools::todo::TodoStatus::Completed => "[x]", + }; + out.push_str(&format!("- {marker} {}\n", item.content)); + } + } + + if !self.subagent_snapshots.is_empty() { + out.push_str("\n### Open Sub-Agents\n"); + for s in &self.subagent_snapshots { + let role = s.assignment.role.as_deref().unwrap_or("—"); + let goal = if s.assignment.objective.is_empty() { + "(no objective set)" + } else { + s.assignment.objective.as_str() + }; + out.push_str(&format!("- `{}` (role: {}) — {}\n", s.agent_id, role, goal)); + } + } + + if let Some(working_set) = self.working_set_summary.as_deref() { + out.push('\n'); + out.push_str(working_set); + out.push('\n'); + } + + Some(out) + } +} + +/// Build the prompt the model uses to produce its `` briefing. +pub const CYCLE_HANDOFF_TEMPLATE: &str = include_str!("prompts/cycle_handoff.md"); + +/// Run the briefing turn. The caller drives this just before swapping the +/// session message buffer. The returned text is the contents of the +/// `` block — outer tags stripped, length-bounded to +/// `max_briefing_tokens` worth of characters as a defensive backstop in case +/// the model ignores the cap. +pub async fn produce_briefing( + client: &DeepSeekClient, + model: &str, + conversation: &[Message], + max_briefing_tokens: usize, +) -> Result { + if conversation.is_empty() { + return Ok(String::new()); + } + + // Append a synthetic instruction asking for the carry_forward block. We + // do not mutate the caller's conversation; this is a one-shot turn. + let mut messages: Vec = conversation.to_vec(); + messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: format!( + "[CYCLE BOUNDARY] {}\n\nProduce your `` block now. \ + Stay under {} tokens. Output only the block — no other text.", + "The next turn starts in a fresh context.", max_briefing_tokens + ), + cache_control: None, + }], + }); + + let request = MessageRequest { + model: model.to_string(), + messages, + max_tokens: u32::try_from(max_briefing_tokens.saturating_mul(2)) + .unwrap_or(8_192) + .max(1_024), + system: Some(SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text: CYCLE_HANDOFF_TEMPLATE.to_string(), + cache_control: None, + }])), + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: Some(false), + // Briefings benefit from low temperature — we want consistent state + // capture, not stylistic variation. + temperature: Some(0.2), + top_p: None, + }; + + let response = client + .create_message(request) + .await + .with_context(|| format!("Cycle briefing turn failed for model {model}"))?; + + let raw = response + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + + let extracted = extract_carry_forward(&raw); + let bounded = enforce_briefing_cap(&extracted, max_briefing_tokens); + Ok(bounded) +} + +/// Pull the contents of the first `...` block +/// out of the raw model response. If the tags are missing, return the trimmed +/// raw text — the caller would rather have *some* briefing than nothing. +#[must_use] +pub fn extract_carry_forward(raw: &str) -> String { + let lower = raw.to_ascii_lowercase(); + let open_tag = ""; + let close_tag = ""; + + if let Some(start) = lower.find(open_tag) { + let after = start + open_tag.len(); + let tail = &raw[after..]; + let tail_lower = &lower[after..]; + if let Some(end) = tail_lower.find(close_tag) { + return tail[..end].trim().to_string(); + } + // Open tag without close tag — take everything after, trimmed. + return tail.trim().to_string(); + } + raw.trim().to_string() +} + +/// Defensive bound on briefing length. Calibrated at ~4 chars/token to match +/// the rest of the codebase's token estimator. +fn enforce_briefing_cap(text: &str, max_tokens: usize) -> String { + let max_chars = max_tokens.saturating_mul(APPROX_CHARS_PER_TOKEN); + if max_chars == 0 { + return String::new(); + } + if text.chars().count() <= max_chars { + return text.to_string(); + } + let mut out: String = text.chars().take(max_chars).collect(); + out.push_str("\n\n[...briefing truncated to fit cap...]"); + out +} + +/// Estimate briefing tokens — same method as `compaction::estimate_tokens` +/// for symmetry: ~4 chars per token. +#[must_use] +pub fn estimate_briefing_tokens(text: &str) -> usize { + text.len().div_ceil(APPROX_CHARS_PER_TOKEN) +} + +/// Header record written as the first line of an archived cycle JSONL file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CycleArchiveHeader { + pub schema_version: u32, + pub cycle: u32, + pub session_id: String, + pub model: String, + pub started: DateTime, + pub ended: DateTime, + pub message_count: usize, +} + +/// Resolve the on-disk archive directory: `~/.deepseek/sessions//cycles`. +fn archive_dir_for(session_id: &str) -> Result { + let home = dirs::home_dir().context("Could not resolve home directory for cycle archive")?; + Ok(home + .join(".deepseek") + .join("sessions") + .join(session_id) + .join("cycles")) +} + +/// Archive a cycle's messages to JSONL on disk and return the path written. +/// +/// The first line is a `CycleArchiveHeader` JSON object; each subsequent +/// line is a single `Message` serialized as JSON. +pub fn archive_cycle( + session_id: &str, + cycle_n: u32, + messages: &[Message], + model: &str, + started: DateTime, +) -> Result { + let dir = archive_dir_for(session_id)?; + std::fs::create_dir_all(&dir).with_context(|| { + format!( + "Failed to create cycle archive directory at {}", + dir.display() + ) + })?; + + let path = dir.join(format!("{cycle_n}.jsonl")); + let header = CycleArchiveHeader { + schema_version: CYCLE_ARCHIVE_SCHEMA_VERSION, + cycle: cycle_n, + session_id: session_id.to_string(), + model: model.to_string(), + started, + ended: Utc::now(), + message_count: messages.len(), + }; + + write_archive_file(&path, &header, messages) + .with_context(|| format!("Failed to write cycle archive at {}", path.display()))?; + + Ok(path) +} + +fn write_archive_file( + path: &Path, + header: &CycleArchiveHeader, + messages: &[Message], +) -> Result<()> { + let tmp_path = path.with_extension("jsonl.tmp"); + { + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&tmp_path)?; + let mut buf = std::io::BufWriter::new(file); + let header_line = serde_json::to_string(header)?; + buf.write_all(header_line.as_bytes())?; + buf.write_all(b"\n")?; + for message in messages { + let line = serde_json::to_string(message)?; + buf.write_all(line.as_bytes())?; + buf.write_all(b"\n")?; + } + // BufWriter flushes on drop, but we want any error surfaced now — + // not silently into the void. + buf.flush()?; + // File handle drops with `buf`. + } + std::fs::rename(&tmp_path, path)?; + Ok(()) +} + +/// Open an archived cycle JSONL for streaming reads. Returns the parsed +/// header and an iterator over messages. Reserved for the future +/// `recall_archive` tool (#127). +#[allow(dead_code)] +pub fn open_archive(path: &Path) -> Result<(CycleArchiveHeader, ArchiveMessageReader)> { + use std::io::{BufRead, BufReader}; + + let file = File::open(path) + .with_context(|| format!("Failed to open cycle archive at {}", path.display()))?; + let mut reader = BufReader::new(file); + let mut header_line = String::new(); + reader.read_line(&mut header_line)?; + let header: CycleArchiveHeader = + serde_json::from_str(header_line.trim()).with_context(|| { + format!( + "Cycle archive at {} is missing a valid header", + path.display() + ) + })?; + + if header.schema_version > CYCLE_ARCHIVE_SCHEMA_VERSION { + anyhow::bail!( + "Cycle archive schema v{} at {} is newer than supported v{}", + header.schema_version, + path.display(), + CYCLE_ARCHIVE_SCHEMA_VERSION + ); + } + + Ok((header, ArchiveMessageReader { reader })) +} + +/// Iterator yielding `Message`s from an opened archive file. Yields `None` +/// when the file is exhausted. Errors propagate through the `Result`. +#[allow(dead_code)] +#[derive(Debug)] +pub struct ArchiveMessageReader { + reader: std::io::BufReader, +} + +#[allow(dead_code)] +impl Iterator for ArchiveMessageReader { + type Item = Result; + + fn next(&mut self) -> Option { + use std::io::BufRead; + + let mut line = String::new(); + match self.reader.read_line(&mut line) { + Ok(0) => None, + Ok(_) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + return self.next(); + } + Some( + serde_json::from_str::(trimmed) + .map_err(|e| anyhow::anyhow!("Archive line parse failed: {e}")), + ) + } + Err(e) => Some(Err(anyhow::Error::new(e))), + } + } +} + +/// Compose the seed messages for the next cycle. +/// +/// Layout (deterministic order): +/// +/// 1. (system prompt is provided separately, not as a `Message`) +/// 2. Optional structured-state user message (todos / plan / working set / +/// sub-agents) — labeled with `[CYCLE STATE]` so the assistant can tell +/// it apart from a real user turn. +/// 3. The model-curated `` briefing — labeled with `[CYCLE +/// BRIEFING]` so the assistant knows it was self-authored on the previous +/// cycle. +/// 4. Optional pending user message that hadn't been sent yet. +/// +/// The original system prompt is composed by the engine and stays separate +/// from this list — the engine sets `session.system_prompt` directly. +#[must_use] +pub fn build_seed_messages( + structured_state_block: Option<&str>, + briefing: Option<&CycleBriefing>, + pending_user_message: Option<&str>, +) -> Vec { + let mut out: Vec = Vec::new(); + + if let Some(state) = structured_state_block + && !state.trim().is_empty() + { + out.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: format!( + "[CYCLE STATE — auto-preserved across the cycle boundary]\n\n{}", + state.trim() + ), + cache_control: None, + }], + }); + // A user message expects an assistant ack so the next real user + // message lands on a clean alternation. We synthesize a one-line ack. + out.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Acknowledged. State carried into the new cycle.".to_string(), + cache_control: None, + }], + }); + } + + if let Some(brief) = briefing + && !brief.briefing_text.trim().is_empty() + { + out.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: format!( + "[CYCLE BRIEFING — written by you on cycle {} at {}]\n\n\n{}\n", + brief.cycle, + brief.timestamp.to_rfc3339(), + brief.briefing_text.trim() + ), + cache_control: None, + }], + }); + out.push(Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: "Briefing absorbed. Continuing.".to_string(), + cache_control: None, + }], + }); + } + + if let Some(pending) = pending_user_message + && !pending.trim().is_empty() + { + out.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: pending.trim().to_string(), + cache_control: None, + }], + }); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ContentBlock, Message}; + use std::path::PathBuf; + use tempfile::tempdir; + + fn user_msg(text: &str) -> Message { + Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } + } + + fn asst_msg(text: &str) -> Message { + Message { + role: "assistant".to_string(), + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } + } + + #[test] + fn cycle_config_default_includes_v4_overrides() { + let cfg = CycleConfig::default(); + assert!(cfg.enabled); + assert!(cfg.per_model.contains_key("deepseek-v4-pro")); + assert!(cfg.per_model.contains_key("deepseek-v4-flash")); + assert_eq!(cfg.threshold_tokens, DEFAULT_CYCLE_THRESHOLD_TOKENS); + assert_eq!(cfg.briefing_max_tokens, DEFAULT_BRIEFING_MAX_TOKENS); + } + + #[test] + fn threshold_for_falls_back_to_default() { + let cfg = CycleConfig::default(); + assert_eq!( + cfg.threshold_for("deepseek-v4-pro"), + DEFAULT_CYCLE_THRESHOLD_TOKENS + ); + assert_eq!( + cfg.threshold_for("unknown-model"), + DEFAULT_CYCLE_THRESHOLD_TOKENS + ); + } + + #[test] + fn threshold_for_uses_per_model_override() { + let mut cfg = CycleConfig::default(); + cfg.per_model.insert( + "deepseek-v4-pro".to_string(), + ModelCycleConfig { + threshold_tokens: 80_000, + briefing_max_tokens: 2_000, + }, + ); + assert_eq!(cfg.threshold_for("deepseek-v4-pro"), 80_000); + assert_eq!(cfg.briefing_max_for("deepseek-v4-pro"), 2_000); + } + + #[test] + fn should_advance_below_threshold_returns_false() { + let cfg = CycleConfig::default(); + assert!(!should_advance_cycle( + 50_000, + 0, + "deepseek-v4-pro", + &cfg, + false + )); + } + + #[test] + fn should_advance_at_threshold_returns_true() { + let cfg = CycleConfig::default(); + assert!(should_advance_cycle( + DEFAULT_CYCLE_THRESHOLD_TOKENS as u64, + 0, + "deepseek-v4-pro", + &cfg, + false + )); + } + + #[test] + fn should_advance_combines_input_and_output() { + let cfg = CycleConfig::default(); + // 400K + 400K = 800K > 768K threshold + assert!(should_advance_cycle( + 400_000, + 400_000, + "deepseek-v4-pro", + &cfg, + false + )); + } + + #[test] + fn in_flight_phase_guard_blocks_advance() { + let cfg = CycleConfig::default(); + assert!(!should_advance_cycle( + DEFAULT_CYCLE_THRESHOLD_TOKENS as u64 * 2, + 0, + "deepseek-v4-pro", + &cfg, + true, + )); + } + + #[test] + fn disabled_config_blocks_advance() { + let cfg = CycleConfig { + enabled: false, + ..Default::default() + }; + assert!(!should_advance_cycle( + DEFAULT_CYCLE_THRESHOLD_TOKENS as u64 * 2, + 0, + "deepseek-v4-pro", + &cfg, + false, + )); + } + + #[test] + fn extract_carry_forward_pulls_block() { + let raw = "Here is your handoff:\n\nDecision A: chose X because Y.\n\nDone."; + assert_eq!(extract_carry_forward(raw), "Decision A: chose X because Y."); + } + + #[test] + fn extract_carry_forward_handles_missing_close_tag() { + let raw = "\nDecision A: chose X."; + // Missing close tag → returns the tail, trimmed. + assert_eq!(extract_carry_forward(raw), "Decision A: chose X."); + } + + #[test] + fn extract_carry_forward_no_tags_returns_trimmed_body() { + let raw = " Decision A: chose X. "; + assert_eq!(extract_carry_forward(raw), "Decision A: chose X."); + } + + #[test] + fn extract_carry_forward_case_insensitive() { + let raw = "\nState here.\n"; + assert_eq!(extract_carry_forward(raw), "State here."); + } + + #[test] + fn enforce_briefing_cap_truncates_oversized_text() { + let max_tokens = 10; // 10 * 4 = 40 chars + let big = "x".repeat(200); + let bounded = enforce_briefing_cap(&big, max_tokens); + assert!(bounded.starts_with(&"x".repeat(40))); + assert!(bounded.contains("[...briefing truncated")); + } + + #[test] + fn enforce_briefing_cap_passes_short_text_through() { + let txt = "hello world"; + assert_eq!(enforce_briefing_cap(txt, 100), "hello world"); + } + + #[test] + fn build_seed_messages_empty_when_all_inputs_empty() { + let seeds = build_seed_messages(None, None, None); + assert!(seeds.is_empty()); + } + + #[test] + fn build_seed_messages_includes_state_briefing_and_pending() { + let briefing = CycleBriefing { + cycle: 1, + timestamp: Utc::now(), + briefing_text: "Decisions: chose A.".to_string(), + token_estimate: 5, + }; + + let seeds = build_seed_messages( + Some("## Cycle State\n- Mode: agent"), + Some(&briefing), + Some("Continue working on issue #124"), + ); + + // Expected layout: state user + ack assistant + briefing user + ack assistant + pending user. + assert_eq!(seeds.len(), 5); + assert_eq!(seeds[0].role, "user"); + assert_eq!(seeds[1].role, "assistant"); + assert_eq!(seeds[2].role, "user"); + assert_eq!(seeds[3].role, "assistant"); + assert_eq!(seeds[4].role, "user"); + + if let ContentBlock::Text { text, .. } = &seeds[0].content[0] { + assert!(text.contains("[CYCLE STATE")); + assert!(text.contains("agent")); + } else { + panic!("expected text block"); + } + if let ContentBlock::Text { text, .. } = &seeds[2].content[0] { + assert!(text.contains("[CYCLE BRIEFING")); + assert!(text.contains("")); + assert!(text.contains("Decisions: chose A.")); + } else { + panic!("expected text block"); + } + if let ContentBlock::Text { text, .. } = &seeds[4].content[0] { + assert_eq!(text, "Continue working on issue #124"); + } else { + panic!("expected text block"); + } + } + + #[test] + fn build_seed_messages_skips_blank_pending() { + let seeds = build_seed_messages(Some("## State"), None, Some(" ")); + // State block + ack — no pending message. + assert_eq!(seeds.len(), 2); + assert_eq!(seeds[0].role, "user"); + assert_eq!(seeds[1].role, "assistant"); + } + + #[test] + fn structured_state_to_system_block_renders_minimal() { + let state = StructuredState { + mode_label: "agent".to_string(), + workspace: PathBuf::from("/tmp/ws"), + cwd: None, + working_set_summary: None, + todo_snapshot: None, + plan_snapshot: None, + subagent_snapshots: Vec::new(), + }; + let block = state.to_system_block().expect("renders"); + assert!(block.contains("Mode: `agent`")); + assert!(block.contains("Workspace: `/tmp/ws`")); + } + + #[test] + fn archive_cycle_writes_jsonl_with_header_and_messages() { + let dir = tempdir().expect("tempdir"); + let session_id = format!("test-session-{}", uuid::Uuid::new_v4()); + + // Redirect dirs::home_dir() into our tempdir. On Unix that reads + // HOME; on Windows it reads USERPROFILE — set both so the test is + // platform-portable. SAFETY: cargo runs each test binary + // single-threaded by default; we do not await across the env + // mutation window. + let original_home = std::env::var("HOME").ok(); + let original_userprofile = std::env::var("USERPROFILE").ok(); + unsafe { + std::env::set_var("HOME", dir.path()); + std::env::set_var("USERPROFILE", dir.path()); + } + + let messages = vec![ + user_msg("hello"), + asst_msg("hi"), + user_msg("can you read Cargo.toml?"), + ]; + + let started = Utc::now(); + let path = archive_cycle(&session_id, 1, &messages, "deepseek-v4-pro", started) + .expect("archive_cycle should succeed"); + + assert!(path.exists(), "archive file should exist on disk"); + assert_eq!(path.file_name().and_then(|s| s.to_str()), Some("1.jsonl")); + + let contents = std::fs::read_to_string(&path).expect("read archive back"); + let mut lines = contents.lines(); + + let header_line = lines.next().expect("header line present"); + let header: CycleArchiveHeader = serde_json::from_str(header_line).expect("header parses"); + assert_eq!(header.cycle, 1); + assert_eq!(header.session_id, session_id); + assert_eq!(header.model, "deepseek-v4-pro"); + assert_eq!(header.message_count, 3); + assert_eq!(header.schema_version, CYCLE_ARCHIVE_SCHEMA_VERSION); + + for expected in &messages { + let line = lines.next().expect("message line present"); + let parsed: Message = serde_json::from_str(line).expect("message parses"); + assert_eq!(&parsed, expected); + } + assert!(lines.next().is_none(), "no extra trailing lines"); + + // Restore env so subsequent tests aren't surprised. + unsafe { + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_userprofile { + Some(value) => std::env::set_var("USERPROFILE", value), + None => std::env::remove_var("USERPROFILE"), + } + } + } + + #[test] + fn open_archive_rejects_newer_schema_version() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("999.jsonl"); + let header = CycleArchiveHeader { + schema_version: CYCLE_ARCHIVE_SCHEMA_VERSION + 5, + cycle: 999, + session_id: "future-session".to_string(), + model: "deepseek-v9".to_string(), + started: Utc::now(), + ended: Utc::now(), + message_count: 0, + }; + let mut payload = serde_json::to_string(&header).unwrap(); + payload.push('\n'); + std::fs::write(&path, payload).unwrap(); + + let err = open_archive(&path).expect_err("must reject newer schema version"); + let msg = format!("{err:#}"); + assert!(msg.contains("newer than supported"), "got: {msg}"); + } + + /// Mock `produce_briefing`-style flow purely client-side: we feed a known + /// raw string through `extract_carry_forward` + `enforce_briefing_cap` + /// and assert the same result we'd produce after a real LLM call. + /// Avoids spinning up a live mock server while still proving the + /// extraction contract. + #[test] + fn briefing_extraction_pipeline_preserves_block() { + let raw = "thinking: ok\n\nDecision: pick lib A; constraint: no async.\n\n"; + let extracted = extract_carry_forward(raw); + let bounded = enforce_briefing_cap(&extracted, 50); + assert_eq!(bounded, "Decision: pick lib A; constraint: no async."); + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 9d937d05..6e2d13af 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1145,6 +1145,14 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "NOVITA_API_KEY", "deepseek auth set --provider novita --api-key \"...\"", ), + crate::config::ApiProvider::Fireworks => ( + "FIREWORKS_API_KEY", + "deepseek auth set --provider fireworks --api-key \"...\"", + ), + crate::config::ApiProvider::Sglang => ( + "SGLANG_API_KEY", + "deepseek auth set --provider sglang --api-key \"...\"", + ), crate::config::ApiProvider::Deepseek => { ("DEEPSEEK_API_KEY", "deepseek login --api-key \"...\"") } @@ -1156,6 +1164,8 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::NvidiaNim => "nvidia_nim", crate::config::ApiProvider::Openrouter => "openrouter", crate::config::ApiProvider::Novita => "novita", + crate::config::ApiProvider::Fireworks => "fireworks", + crate::config::ApiProvider::Sglang => "sglang", crate::config::ApiProvider::Deepseek => "deepseek", } ); diff --git a/crates/tui/src/prompts/base.md b/crates/tui/src/prompts/base.md index 896fd0e8..539ccda3 100644 --- a/crates/tui/src/prompts/base.md +++ b/crates/tui/src/prompts/base.md @@ -20,11 +20,19 @@ The user can see their own message. Use the first line to show forward motion. You are a "managed genius" — you excel at individual tasks, but your superpower is decomposing complex work. **Always decompose before you act.** A few minutes spent planning saves many minutes of thrashing. +Use three decomposition patterns from the V4 paper (arXiv:2512.24601), selected by task scope: + +**PREVIEW** — Before diving into a large task, survey the terrain. Scan directory structure (`list_dir`), file headers, module trees. Identify problem boundaries and estimate complexity. A 30-second preview prevents hours of wrong-path exploration. + +**CHUNK + map-reduce** — When a task exceeds single-pass capacity: split into independent sub-tasks, process each independently (parallel where possible via parallel tool calls or `agent_swarm`), then synthesize findings into a coherent whole. Track chunks with `todo_write`. + +**RECURSIVE** — When sub-tasks reveal sub-problems: decompose recursively until each leaf is tractable. Maintain the task tree via `update_plan` (strategy) layered above `todo_write` (leaf tasks). Propagate findings upward when sub-problems resolve. + Your default workflow for any non-trivial request: 1. **`todo_write`** — break the work into concrete, verifiable tasks. Mark the first one `in_progress`. This populates the sidebar so the user can see what you're doing. 2. **Execute** — work through each todo, updating status as you go. 3. **For complex initiatives**, layer `update_plan` (high-level strategy) above `todo_write` (granular steps). -4. **For parallel work**, spawn sub-agents (`agent_spawn` / `agent_swarm`) — each does one thing well. Link them to plan/todo items in your thinking. +4. **For parallel work**, spawn sub-agents (`agent_spawn` / `agent_swarm`) — each does one thing well. Link them to plan/todo items in your thinking. Batch independent tool calls in a single turn. 5. **For long inputs that don't fit in your context** (whole files, transcripts, multi-doc corpora) or when you need recursive sub-LLM work, use `rlm` — it loads the input into a Python REPL as `context` and runs sub-LLM calls there so the long string never enters your window. 6. **For persistent cross-session memory**, use `note` sparingly for important decisions, open blockers, and architectural context. @@ -35,6 +43,34 @@ You have a 1 M-token context window. When usage creeps above ~80%, suggest `/c Model notes: DeepSeek V4 models emit *thinking tokens* (`ContentBlock::Thinking`) before final answers. These are invisible to the user but count against context. Cost/token estimates are approximate; treat them as a rough guide. +## Your V4 Characteristics + +You run on V4 architecture. Understanding the internals helps you self-manage: + +**Degradation curve.** Retrieval quality holds well to ~256K tokens, then degrades rapidly. Keep your active working set below ~256K. Older verbatim messages persist but are harder to retrieve accurately — treat `` seams as navigational markers, not a working-memory substitute. + +**Prefix cache economics.** V4 caches shared prefixes at 128-token granularity with ~90% cost discount. Prefer appending to existing messages over mutating old ones — deletion or replacement breaks the cache and increases cost. Structure output to maximize prefix reuse across turns. + +**Thinking token strategy.** Thinking tokens count against context and replay across turns (the `reasoning_content` rule). Use them strategically: skip for lookups, light for simple code generation, deep for architecture and debugging. Cache conclusions in concise inline summaries rather than re-deriving each turn. + +**Parallel execution.** Batch independent reads, searches, and greps into a single turn. Never serialize operations that can run concurrently — parallel tool calls share the same turn and finish faster. + +## Thinking Budget + +Match thinking depth to task complexity. Overthinking wastes tokens; underthinking causes rework. + +| Task type | Thinking depth | Rationale | +|-----------|---------------|-----------| +| Simple factual lookup (read, search) | Skip | Answer is immediate | +| Tool output interpretation | Light | Verify result matches intent | +| Code generation (single function) | Light | Pattern-matching | +| Multi-file refactor | Medium | Cross-file dependencies | +| Debugging (error to root cause) | Deep | Hypothesis generation | +| Architecture design | Deep | Trade-offs, constraints | +| Security review | Deep | Adversarial reasoning | + +When context is deep (past a soft seam): cache reasoning conclusions in concise inline summaries, reference prior conclusions rather than re-deriving, and remember that thinking tokens in the verbatim window survive compaction. Think once, reference many times. + ## Toolbox (fast reference — tool descriptions are authoritative) - **Planning / tracking**: `update_plan` (high-level strategy), `todo_write` (granular task list — use this first), `todo_add` / `todo_update` / `todo_list` (legacy single-item ops), `note` (persistent memory). diff --git a/crates/tui/src/seam_manager.rs b/crates/tui/src/seam_manager.rs index eb74683e..60fa5c47 100644 --- a/crates/tui/src/seam_manager.rs +++ b/crates/tui/src/seam_manager.rs @@ -95,13 +95,19 @@ pub struct SeamMetadata { /// Which level (1, 2, or 3). pub level: u8, /// Message range covered (inclusive-exclusive indices). + /// Reserved for future diagnostic use. + #[allow(dead_code)] pub start_idx: usize, + #[allow(dead_code)] pub end_idx: usize, /// Approximate token count of the summary. + #[allow(dead_code)] pub token_estimate: usize, /// When the seam was produced. + #[allow(dead_code)] pub timestamp: DateTime, /// Model that produced it. + #[allow(dead_code)] pub model: String, } @@ -162,7 +168,11 @@ impl SeamManager { } /// Check whether the hard cycle boundary is crossed. + /// + /// Note: not currently called — cycle detection uses an inline check. + /// Kept as the canonical boundary definition for future wiring. #[must_use] + #[allow(dead_code)] pub fn should_cycle(&self, cumulative_tokens: usize) -> bool { self.config.enabled && cumulative_tokens >= self.config.cycle_threshold } @@ -542,10 +552,10 @@ impl SeamManager { for msg in messages { if msg.role == "assistant" { for block in &msg.content { - if let ContentBlock::Text { text, .. } = block { - if text.contains("= config.cycle_threshold); - assert!(!(700_000 >= config.cycle_threshold)); + assert!(700_000 < config.cycle_threshold); } #[test] diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 1da978a4..5a56d59f 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -263,7 +263,7 @@ impl ToolSpec for EditFileTool { } fn description(&self) -> &'static str { - "Replace text in a file using search/replace." + "Replace text in a file using search/replace. Required: 'path' (file to edit), 'search' (exact text to find), 'replace' (text to substitute)." } fn input_schema(&self) -> Value { @@ -603,6 +603,39 @@ mod tests { assert!(err.to_string().contains("not found")); } + /// #157 — When the model uses `replacement` instead of `replace`, + /// the error should name the provided fields so the model can + /// self-correct without a second round-trip. + #[tokio::test] + async fn test_edit_file_wrong_param_name_shows_provided_fields() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + + let test_file = tmp.path().join("test.txt"); + fs::write(&test_file, "hello world").expect("write"); + + let tool = EditFileTool; + // Model uses `replacement` instead of `replace`. + let result = tool + .execute( + json!({"path": "test.txt", "search": "hello", "replacement": "hi"}), + &ctx, + ) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + // The error must name both the missing field AND the provided ones. + assert!( + err.contains("missing required field 'replace'"), + "error must name the missing field: {err}" + ); + assert!( + err.contains("Input provided:") || err.contains("provided:"), + "error must list the fields the model did supply: {err}" + ); + } + #[tokio::test] async fn test_list_dir_tool() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/subagent/mailbox.rs b/crates/tui/src/tools/subagent/mailbox.rs index fce6499b..ae45fcae 100644 --- a/crates/tui/src/tools/subagent/mailbox.rs +++ b/crates/tui/src/tools/subagent/mailbox.rs @@ -58,6 +58,15 @@ pub enum MailboxMessage { Failed { agent_id: String, error: String }, /// Cancellation propagated to this agent. Cancelled { agent_id: String }, + /// Incremental token usage from a sub-agent's API call. + /// Published after each turn so the parent's cost counter updates live. + TokenUsage { + agent_id: String, + /// Prompt tokens consumed (input, including cached). + prompt_tokens: u32, + /// Completion tokens consumed (output). + completion_tokens: u32, + }, } impl MailboxMessage { @@ -72,7 +81,8 @@ impl MailboxMessage { | Self::ToolCallCompleted { agent_id, .. } | Self::Completed { agent_id, .. } | Self::Failed { agent_id, .. } - | Self::Cancelled { agent_id } => agent_id, + | Self::Cancelled { agent_id } + | Self::TokenUsage { agent_id, .. } => agent_id, Self::ChildSpawned { child_id, .. } => child_id, } } @@ -90,6 +100,18 @@ impl MailboxMessage { status: status.into(), } } + + pub(crate) fn token_usage( + agent_id: impl Into, + prompt_tokens: u32, + completion_tokens: u32, + ) -> Self { + Self::TokenUsage { + agent_id: agent_id.into(), + prompt_tokens, + completion_tokens, + } + } } /// One delivery: a sequence number plus the message. The sequence is @@ -434,6 +456,14 @@ mod tests { }, "a8", ), + ( + MailboxMessage::TokenUsage { + agent_id: "a9".into(), + prompt_tokens: 100, + completion_tokens: 50, + }, + "a9", + ), ]; for (msg, expected) in cases { assert_eq!(msg.agent_id(), expected, "extract failed for {msg:?}"); diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 43f7bbb0..b48f9bc2 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -2725,6 +2725,16 @@ async fn run_subagent( }; let mut tool_uses = Vec::new(); + + // Report token usage so the parent's cost counter updates live. + if let Some(mb) = runtime.mailbox.as_ref() { + let _ = mb.send(MailboxMessage::token_usage( + &agent_id, + response.usage.input_tokens, + response.usage.output_tokens, + )); + } + for block in &response.content { match block { ContentBlock::Text { text, .. } if !text.trim().is_empty() => { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 8a3818bc..9100087b 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -520,6 +520,8 @@ pub struct App { pub tool_log: Vec, /// Session cost tracking pub session_cost: f64, + /// Running cost from active sub-agents (updated live via mailbox). + pub subagent_cost: f64, /// Active skill to apply to next user message pub active_skill: Option, /// Tool call cells by tool id (for cells already finalized in `history`). @@ -923,6 +925,7 @@ impl App { todos: new_shared_todo_list(), tool_log: Vec::new(), session_cost: 0.0, + subagent_cost: 0.0, active_skill: None, tool_cells: HashMap::new(), tool_details_by_cell: HashMap::new(), diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 961a40ef..f2457033 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -97,6 +97,25 @@ pub enum HistoryCell { streaming: bool, duration_secs: Option, }, + /// An `` seam block produced by the Flash seam manager + /// (issue #159). Rendered dimmed/italic with a level + range label so + /// the user can see at a glance where context seams exist. + ArchivedContext { + /// Seam level (1, 2, 3, or 0 for cycle-level). + level: u8, + /// Message range covered (e.g. "msg 0-128"). + range: String, + /// Token estimate string (e.g. "~2500"). + tokens: String, + /// Density label (e.g. "~2,500 tokens"). + density: String, + /// Model that produced the summary. + model: String, + /// RFC 3339 timestamp. + timestamp: String, + /// The summary text content. + summary: String, + }, Tool(ToolCell), /// Live in-transcript card for sub-agent activity (issue #128). Owns /// either a single `DelegateCard` or a multi-worker `FanoutCard`; the @@ -188,6 +207,9 @@ impl HistoryCell { } => render_thinking(content, width, *streaming, *duration_secs, false, false), HistoryCell::Tool(cell) => cell.lines_with_motion(width, false), HistoryCell::SubAgent(cell) => cell.lines(width), + HistoryCell::ArchivedContext { .. } => { + render_archived_context(self, width, false) + } } } @@ -249,6 +271,9 @@ impl HistoryCell { ), HistoryCell::System { .. } | HistoryCell::Error { .. } => self.lines(width), HistoryCell::SubAgent(cell) => cell.lines(width), + HistoryCell::ArchivedContext { .. } => { + render_archived_context(self, width, options.low_motion) + } } } @@ -293,6 +318,9 @@ impl HistoryCell { ), HistoryCell::Tool(cell) => cell.transcript_lines(width), HistoryCell::SubAgent(cell) => cell.lines(width), + HistoryCell::ArchivedContext { .. } => { + render_archived_context(self, width, true) + } } } @@ -317,6 +345,172 @@ impl HistoryCell { } } +/// Parse an `` block from an assistant Text block. +/// +/// Returns `Some(HistoryCell::ArchivedContext)` when the text contains a +/// well-formed `...` block, or `None` +/// if the text is regular assistant content. +fn parse_archived_context(text: &str) -> Option { + let text = text.trim(); + if !text.starts_with("") { + return None; + } + + let tag_end = text.find('>')?; + let tag = &text[..tag_end]; + + let level = tag + .split(' ') + .find(|part| part.starts_with("level=")) + .and_then(|part| part.split('"').nth(1)) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + let range = tag + .split(' ') + .find(|part| part.starts_with("range=")) + .and_then(|part| part.split('"').nth(1)) + .unwrap_or("") + .to_string(); + + let tokens = tag + .split(' ') + .find(|part| part.starts_with("tokens=")) + .and_then(|part| part.split('"').nth(1)) + .unwrap_or("") + .to_string(); + + let density = tag + .split(' ') + .find(|part| part.starts_with("density=")) + .and_then(|part| part.split('"').nth(1)) + .unwrap_or("") + .to_string(); + + let model = tag + .split(' ') + .find(|part| part.starts_with("model=")) + .and_then(|part| part.split('"').nth(1)) + .unwrap_or("") + .to_string(); + + let timestamp = tag + .split(' ') + .find(|part| part.starts_with("timestamp=")) + .and_then(|part| part.split('"').nth(1)) + .unwrap_or("") + .to_string(); + + let close_tag = text.rfind("")?; + let summary_start = tag_end + 1; + let summary = text[summary_start..close_tag].trim().to_string(); + + Some(HistoryCell::ArchivedContext { + level, + range, + tokens, + density, + model, + timestamp, + summary, + }) +} + +/// Render an `` block with dimmed/italic styling. +fn render_archived_context(cell: &HistoryCell, width: u16, _low_motion: bool) -> Vec> { + let HistoryCell::ArchivedContext { + level, + range, + tokens, + density, + model, + timestamp, + summary, + } = cell + else { + return Vec::new(); + }; + + let body = if summary.is_empty() { + "(no summary)".to_string() + } else { + summary.clone() + }; + + let label = format!("Context L{level}"); + let label_style = Style::default() + .fg(palette::TEXT_DIM) + .add_modifier(Modifier::BOLD); + let body_style = Style::default() + .fg(palette::TEXT_DIM) + .italic(); + + let content_width = width.saturating_sub(4).max(1); + + let mut lines = Vec::new(); + + let range_display = if range.is_empty() { + String::new() + } else { + range.to_string() + }; + let mut header = format!("{label} {range_display}"); + if !tokens.is_empty() { + header.push_str(&format!(" {tokens}")); + } + if !density.is_empty() && density != tokens { + header.push_str(&format!(" {density}")); + } + lines.push(Line::from(Span::styled(header, label_style))); + + let model_display = if model.is_empty() { + String::new() + } else { + format!("via {model}") + }; + let ts_display = if timestamp.is_empty() { + String::new() + } else { + timestamp.clone() + }; + let mut sub = String::new(); + if !model_display.is_empty() { + sub.push_str(&model_display); + } + if !ts_display.is_empty() { + if !sub.is_empty() { + sub.push_str(" · "); + } + sub.push_str(&ts_display); + } + if !sub.is_empty() { + lines.push(Line::from(Span::styled( + sub, + Style::default().fg(palette::TEXT_MUTED), + ))); + } + + let rendered = crate::tui::markdown_render::render_markdown(&body, content_width, body_style); + for (idx, line) in rendered.into_iter().enumerate() { + if idx == 0 { + let mut spans = vec![Span::styled( + "▏ ", + Style::default().fg(palette::TEXT_DIM), + )]; + spans.extend(line.spans); + lines.push(Line::from(spans)); + } else { + let mut spans = vec![Span::raw(" ")]; + spans.extend(line.spans); + lines.push(Line::from(spans)); + } + } + + lines.push(Line::from("")); + + lines +} + /// Convert a message into history cells for rendering. #[must_use] pub fn history_cells_from_message(msg: &Message) -> Vec { @@ -324,7 +518,15 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { for block in &msg.content { match block { - ContentBlock::Text { text, .. } => match msg.role.as_str() { + ContentBlock::Text { text, .. } => { + // Check if this is an `` block. + if msg.role == "assistant" + && let Some(archived) = parse_archived_context(text) + { + cells.push(archived); + continue; + } + match msg.role.as_str() { "user" => { if let Some(HistoryCell::User { content }) = cells.last_mut() { if !content.is_empty() { @@ -363,6 +565,7 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { } } _ => {} + } }, ContentBlock::Thinking { thinking } => { if let Some(HistoryCell::Thinking { content, .. }) = cells.last_mut() { diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 28332377..95284547 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -90,6 +90,8 @@ impl ProviderPickerView { ApiProvider::NvidiaNim => "NVIDIA_API_KEY", ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", + ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Sglang => "SGLANG_API_KEY", } } @@ -339,7 +341,7 @@ mod tests { } #[test] - fn picker_lists_all_four_providers() { + fn picker_lists_all_six_providers() { let config = Config::default(); let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); let names: Vec<_> = picker @@ -349,7 +351,7 @@ mod tests { .collect(); assert_eq!( names, - vec!["DeepSeek", "NVIDIA NIM", "OpenRouter", "Novita AI"] + vec!["DeepSeek", "NVIDIA NIM", "OpenRouter", "Novita AI", "Fireworks AI", "SGLang"] ); } diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index d9fbcda4..7fe3e28e 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -184,6 +184,7 @@ impl TranscriptViewCache { | HistoryCell::Error { .. } | HistoryCell::Tool(_) | HistoryCell::SubAgent(_) + | HistoryCell::ArchivedContext { .. } ), is_tool_groupable, }); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e308d7ab..e475dc19 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3408,6 +3408,8 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::NvidiaNim => Some("NIM"), crate::config::ApiProvider::Openrouter => Some("OR"), crate::config::ApiProvider::Novita => Some("Novita"), + crate::config::ApiProvider::Fireworks => Some("Fireworks"), + crate::config::ApiProvider::Sglang => Some("SGLang"), }; let header_data = HeaderData::new( app.mode, @@ -3965,6 +3967,8 @@ async fn apply_provider_picker_api_key( ApiProvider::NvidiaNim => &mut providers.nvidia_nim, ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, + ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Sglang => &mut providers.sglang, }; entry.api_key = Some(api_key); } @@ -4277,7 +4281,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { // `working...` pulse stays even in low-motion mode so the user still // sees that something is happening. if !app.low_motion { - let strip_frame = now_ms / 150; + let strip_frame = now_ms; props.working_strip_frame = Some(strip_frame); } } else if props.state_label == "ready" @@ -4482,9 +4486,9 @@ fn render_footer_from( } else { Vec::new() }; - let cost = if has(S::Cost) && app.session_cost > 0.001 { + let cost = if has(S::Cost) && app.session_cost + app.subagent_cost > 0.001 { vec![Span::styled( - format!("${:.2}", app.session_cost), + format!("${:.2}", app.session_cost + app.subagent_cost), Style::default().fg(palette::TEXT_MUTED), )] } else { @@ -4576,9 +4580,9 @@ fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { let agents_spans = crate::tui::widgets::footer_agents_chip(running_agent_count(app)); let replay_spans = footer_reasoning_replay_spans(app); let cache_spans = footer_cache_spans(app); - let cost_spans = if app.session_cost > 0.001 { + let cost_spans = if app.session_cost + app.subagent_cost > 0.001 { vec![Span::styled( - format!("${:.2}", app.session_cost), + format!("${:.2}", app.session_cost + app.subagent_cost), Style::default().fg(palette::TEXT_MUTED), )] } else { @@ -4633,7 +4637,11 @@ fn footer_cache_spans(app: &App) -> Vec> { let Some(hit_tokens) = app.last_prompt_cache_hit_tokens else { return Vec::new(); }; - let miss_tokens = app.last_prompt_cache_miss_tokens.unwrap_or(0); + let miss_tokens = app.last_prompt_cache_miss_tokens.unwrap_or_else(|| { + app.last_prompt_tokens + .unwrap_or(0) + .saturating_sub(hit_tokens) + }); let total = hit_tokens.saturating_add(miss_tokens); if total == 0 { return Vec::new(); @@ -5329,6 +5337,7 @@ fn open_tool_details_pager(app: &mut App) -> bool { HistoryCell::Thinking { .. } => "Reasoning".to_string(), HistoryCell::Tool(_) => "Message".to_string(), HistoryCell::SubAgent(_) => "Sub-agent".to_string(), + HistoryCell::ArchivedContext { .. } => "Archived Context".to_string(), }; let width = app .last_transcript_area @@ -5492,6 +5501,21 @@ fn handle_subagent_mailbox(app: &mut App, _seq: u64, message: &MailboxMessage) { DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout, }; + // Accumulate sub-agent token costs for the real-time footer counter (#166). + if let MailboxMessage::TokenUsage { + prompt_tokens, + completion_tokens, + .. + } = message + { + if let Some(cost) = + crate::pricing::calculate_turn_cost(&app.model, *prompt_tokens, *completion_tokens) + { + app.subagent_cost += cost; + } + return; // No card visual change needed; the footer handles display. + } + // Resolve (or allocate) the target cell for this envelope. ChildSpawned // is special — it always belongs to the active fanout card if one // exists; otherwise it seeds a new one. diff --git a/crates/tui/src/tui/ui.rs.bak3 b/crates/tui/src/tui/ui.rs.bak3 new file mode 100644 index 00000000..e308d7ab --- /dev/null +++ b/crates/tui/src/tui/ui.rs.bak3 @@ -0,0 +1,6635 @@ +//! TUI event loop and rendering logic for `DeepSeek` CLI. + +use std::collections::HashSet; +use std::io::{self, Stdout}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use crossterm::{ + event::{ + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, + }, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::Style, + text::Span, + widgets::Block, +}; +use tracing; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::audit::log_sensitive_event; +use crate::client::DeepSeekClient; +use crate::commands; +use crate::compaction::estimate_input_tokens_conservative; +use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL}; +use crate::core::coherence::CoherenceState; +use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; +use crate::core::events::Event as EngineEvent; +use crate::core::ops::Op; +use crate::hooks::HookEvent; +use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model}; +use crate::palette; +use crate::prompts; +use crate::session_manager::{ + OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager, + create_saved_session_with_mode, update_session, +}; +use crate::task_manager::{ + NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskStatus, + TaskSummary, +}; +use crate::tools::ReviewOutput; +use crate::tools::spec::{ToolError, ToolResult}; +use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus}; +use crate::tui::command_palette::{ + CommandPaletteView, build_entries as build_command_palette_entries, +}; +use crate::tui::context_inspector::build_context_inspector_text; +use crate::tui::event_broker::EventBroker; +use crate::tui::live_transcript::LiveTranscriptOverlay; +use crate::tui::onboarding; +use crate::tui::pager::PagerView; +use crate::tui::plan_prompt::PlanPromptView; +use crate::tui::scrolling::{ScrollDirection, TranscriptScroll}; +use crate::tui::selection::TranscriptSelectionPoint; +use crate::tui::session_picker::SessionPickerView; +use crate::tui::ui_text::{history_cell_to_text, line_to_plain, slice_text, text_display_width}; +use crate::tui::user_input::UserInputView; + +use super::active_cell::ActiveCell; +use super::app::{ + App, AppAction, AppMode, OnboardingState, QueuedMessage, SidebarFocus, StatusToastLevel, + SubmitDisposition, TaskPanelEntry, ToolDetailRecord, TuiOptions, +}; +use super::approval::{ + ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision, +}; +use super::history::{ + DiffPreviewCell, ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, + McpToolCell, PatchSummaryCell, PlanStep, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, + ViewImageCell, WebSearchCell, history_cells_from_message, summarize_mcp_output, + summarize_tool_args, summarize_tool_output, +}; +use super::slash_menu::{ + apply_slash_menu_selection, try_autocomplete_slash_command, visible_slash_menu_entries, +}; +use super::views::{ConfigView, HelpView, ModalKind, ViewEvent}; +use super::widgets::pending_input_preview::{ContextPreviewItem, PendingInputPreview}; +use super::widgets::{ + ChatWidget, ComposerWidget, FooterProps, FooterToast, FooterWidget, HeaderData, HeaderWidget, + Renderable, +}; + +// === Constants === + +/// Upper bound on slash-menu entries returned to the renderer. The composer's +/// render path already paginates with center-tracking (see +/// `widgets::ComposerWidget::render`), so this only needs to be high enough to +/// encompass the full filtered command list — never the visible-row budget. +/// Bumped from 6 to 128 to fix #64 (selection couldn't reach commands beyond +/// the visible window because the source list itself was capped). +const SLASH_MENU_LIMIT: usize = 128; +const MENTION_MENU_LIMIT: usize = 6; +const MIN_CHAT_HEIGHT: u16 = 3; +const MIN_COMPOSER_HEIGHT: u16 = 2; +const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0; +const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0; +const UI_IDLE_POLL_MS: u64 = 48; +const UI_ACTIVE_POLL_MS: u64 = 24; +// Forced repaint cadence while a turn is live (model loading, compacting, +// sub-agents running). Drives the footer water-spout animation as well as +// the per-tool spinner pulse — keep this fast enough that the spout reads as +// motion (~12 fps) instead of teleport-frames. +const UI_STATUS_ANIMATION_MS: u64 = 80; +const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15; +const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100; + +/// Run the interactive TUI event loop. +/// +/// # Examples +/// +/// ```ignore +/// # use crate::config::Config; +/// # use crate::tui::TuiOptions; +/// # async fn example(config: &Config, options: TuiOptions) -> anyhow::Result<()> { +/// crate::tui::run_tui(config, options).await +/// # } +/// ``` +pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> { + let use_alt_screen = options.use_alt_screen; + let use_mouse_capture = options.use_mouse_capture; + let use_bracketed_paste = options.use_bracketed_paste; + enable_raw_mode()?; + let mut stdout = io::stdout(); + if use_alt_screen { + execute!(stdout, EnterAlternateScreen)?; + } + if use_mouse_capture { + execute!(stdout, EnableMouseCapture)?; + } + if use_bracketed_paste { + execute!(stdout, EnableBracketedPaste)?; + } + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let event_broker = EventBroker::new(); + + // Local mutable copy so runtime config flips (e.g. `/provider` switch) + // can rebuild the API client without restarting the process. + let mut config = config.clone(); + let config = &mut config; + let mut app = App::new(options.clone(), config); + + // Load existing session if resuming. + if let Some(ref session_id) = options.resume_session_id + && let Ok(manager) = SessionManager::default_location() + { + // Try to load by prefix or full ID + let load_result: std::io::Result> = + if session_id == "latest" { + // Special case: resume the most recent session + match manager.get_latest_session() { + Ok(Some(meta)) => manager.load_session(&meta.id).map(Some), + Ok(None) => Ok(None), + Err(e) => Err(e), + } + } else { + manager.load_session_by_prefix(session_id).map(Some) + }; + + match load_result { + Ok(Some(saved)) => { + app.api_messages.clone_from(&saved.messages); + app.model.clone_from(&saved.metadata.model); + app.update_model_compaction_budget(); + app.workspace.clone_from(&saved.metadata.workspace); + app.current_session_id = Some(saved.metadata.id.clone()); + app.total_tokens = u32::try_from(saved.metadata.total_tokens).unwrap_or(u32::MAX); + app.total_conversation_tokens = app.total_tokens; + app.last_prompt_tokens = None; + app.last_completion_tokens = None; + app.last_prompt_cache_hit_tokens = None; + app.last_prompt_cache_miss_tokens = None; + app.last_reasoning_replay_tokens = None; + if let Some(prompt) = saved.system_prompt { + app.system_prompt = Some(SystemPrompt::Text(prompt)); + } + // Convert saved messages to HistoryCell format for display + app.clear_history(); + app.push_history_cell(HistoryCell::System { + content: format!( + "Resumed session: {} ({})", + saved.metadata.title, + &saved.metadata.id[..8.min(saved.metadata.id.len())] + ), + }); + + for msg in &saved.messages { + app.extend_history(history_cells_from_message(msg)); + } + app.mark_history_updated(); + app.status_message = Some(format!( + "Resumed session: {}", + &saved.metadata.id[..8.min(saved.metadata.id.len())] + )); + } + Ok(None) => { + app.status_message = Some("No sessions found to resume".to_string()); + } + Err(e) => { + app.status_message = Some(format!("Failed to load session: {e}")); + } + } + } + + if let Ok(manager) = SessionManager::default_location() { + match manager.load_offline_queue_state() { + Ok(Some(state)) => { + app.queued_messages = state + .messages + .into_iter() + .map(queued_session_to_ui) + .collect(); + app.queued_draft = state.draft.map(queued_session_to_ui); + if app.status_message.is_none() && app.queued_message_count() > 0 { + app.status_message = Some(format!( + "Recovered {} queued message(s)", + app.queued_message_count() + )); + } + } + Ok(None) => {} + Err(err) => { + if app.status_message.is_none() { + app.status_message = Some(format!("Failed to restore offline queue: {err}")); + } + } + } + } + + let engine_config = build_engine_config(&app, config); + + // Spawn the Engine - it will handle all API communication + let engine_handle = spawn_engine(engine_config, config); + + if !app.api_messages.is_empty() { + let _ = engine_handle + .send(Op::SyncSession { + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + }) + .await; + } + + // Fire session start hook + { + let context = app.base_hook_context(); + let _ = app.execute_hooks(HookEvent::SessionStart, &context); + } + + let task_manager = TaskManager::start( + TaskManagerConfig::from_runtime( + config, + app.workspace.clone(), + Some(app.model.clone()), + Some(app.max_subagents.clamp(1, 4)), + ), + config.clone(), + ) + .await?; + app.task_panel = task_manager + .list_tasks(Some(10)) + .await + .into_iter() + .map(task_summary_to_panel_entry) + .collect(); + + let result = run_event_loop( + &mut terminal, + &mut app, + config, + engine_handle, + task_manager, + &event_broker, + ) + .await; + + // Fire session end hook + { + let context = app.base_hook_context(); + let _ = app.execute_hooks(HookEvent::SessionEnd, &context); + } + + // Clear crash-recovery checkpoint on normal exit so the next launch starts fresh. + clear_checkpoint(); + + disable_raw_mode()?; + if use_alt_screen { + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + } + if use_mouse_capture { + execute!(terminal.backend_mut(), DisableMouseCapture)?; + } + if use_bracketed_paste { + execute!(terminal.backend_mut(), DisableBracketedPaste)?; + } + terminal.show_cursor()?; + + result +} + +fn build_engine_config(app: &App, config: &Config) -> EngineConfig { + EngineConfig { + model: app.model.clone(), + workspace: app.workspace.clone(), + allow_shell: app.allow_shell, + trust_mode: app.trust_mode, + notes_path: config.notes_path(), + mcp_config_path: config.mcp_config_path(), + // Effectively unlimited. V4 has a 1M context window and the user + // wants the model running until it's actually done. The previous cap + // of 100 hit the ceiling on long multi-step plans (wide refactors, + // sub-agent orchestration) and presented as the agent "giving up + // mid-task". `u32::MAX` is the type ceiling; users can still + // interrupt with Ctrl+C / Esc, and a turn naturally ends when the + // model stops emitting tool calls. A real runaway is rare and + // human-noticeable; we trust the operator over a hard step cap. + max_steps: u32::MAX, + max_subagents: app.max_subagents, + features: config.features(), + compaction: app.compaction_config(), + cycle: app.cycle_config(), + capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config), + todos: app.todos.clone(), + plan_state: app.plan_state.clone(), + max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, + network_policy: config.network.clone().map(|toml_cfg| { + crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime()) + }), + snapshots_enabled: config.snapshots_config().enabled, + lsp_config: config + .lsp + .clone() + .map(crate::config::LspConfigToml::into_runtime), + } +} + +#[allow(clippy::too_many_lines)] +async fn run_event_loop( + terminal: &mut Terminal>, + app: &mut App, + config: &mut Config, + mut engine_handle: EngineHandle, + task_manager: SharedTaskManager, + event_broker: &EventBroker, +) -> Result<()> { + // Track streaming state + let mut current_streaming_text = String::new(); + let mut last_queue_state = (app.queued_messages.clone(), app.queued_draft.clone()); + let mut last_task_refresh = Instant::now() + .checked_sub(Duration::from_secs(2)) + .unwrap_or_else(Instant::now); + let mut last_status_frame = Instant::now() + .checked_sub(Duration::from_millis(UI_STATUS_ANIMATION_MS)) + .unwrap_or_else(Instant::now); + // 120 FPS draw cap. Without this we redraw on every SSE chunk during a + // long stream — wasted work the user can't perceive. See + // `tui::frame_rate_limiter` for the rationale; ports the small piece of + // codex's frame coalescing that maps cleanly onto our poll-based loop. + let mut frame_rate_limiter = crate::tui::frame_rate_limiter::FrameRateLimiter::default(); + + loop { + if last_task_refresh.elapsed() >= Duration::from_millis(2500) { + let tasks = task_manager.list_tasks(Some(10)).await; + app.task_panel = tasks.into_iter().map(task_summary_to_panel_entry).collect(); + last_task_refresh = Instant::now(); + app.needs_redraw = true; + } + + // First, poll for engine events (non-blocking) + let mut received_engine_event = false; + let mut transcript_batch_updated = false; + let mut queued_to_send: Option = None; + { + let mut rx = engine_handle.rx_event.write().await; + while let Ok(event) = rx.try_recv() { + received_engine_event = true; + match event { + EngineEvent::MessageStarted { .. } => { + // Assistant text starting after parallel tool work + // means the tool group is done. Flush the active + // cell first so the message lands BELOW the + // committed tool group (Codex pattern: streamed + // assistant content always flows after work). + app.flush_active_cell(); + current_streaming_text.clear(); + app.streaming_state.reset(); + app.streaming_state.start_text(0, None); + app.streaming_message_index = None; + } + EngineEvent::MessageDelta { content, .. } => { + let sanitized = sanitize_stream_chunk(&content); + if sanitized.is_empty() { + continue; + } + // First delta of a fresh stream has no streaming + // cell yet; flush active so the tool group settles + // before the assistant prose appears below it. + if app.streaming_message_index.is_none() { + app.flush_active_cell(); + } + current_streaming_text.push_str(&sanitized); + let index = ensure_streaming_assistant_history_cell(app); + app.streaming_state.push_content(0, &sanitized); + let committed = app.streaming_state.commit_text(0); + if !committed.is_empty() { + append_streaming_text(app, index, &committed); + transcript_batch_updated = true; + } + } + EngineEvent::MessageComplete { .. } => { + if let Some(index) = app.streaming_message_index.take() { + let remaining = app.streaming_state.finalize_block_text(0); + if !remaining.is_empty() { + append_streaming_text(app, index, &remaining); + } + if let Some(HistoryCell::Assistant { streaming, .. }) = + app.history.get_mut(index) + { + *streaming = false; + } + // Streaming flag flipped — the cell's compact / + // transcript variants render slightly + // differently, so bump its revision so the cache + // refreshes this row only. + app.bump_history_cell(index); + transcript_batch_updated = true; + } + + let mut blocks = Vec::new(); + let thinking = app.last_reasoning.take(); + if let Some(thinking) = thinking { + blocks.push(ContentBlock::Thinking { thinking }); + } + if !current_streaming_text.is_empty() { + blocks.push(ContentBlock::Text { + text: current_streaming_text.clone(), + cache_control: None, + }); + } + for (id, name, input) in app.pending_tool_uses.drain(..) { + blocks.push(ContentBlock::ToolUse { + id, + name, + input, + caller: None, + }); + } + + // DeepSeek rejects assistant messages that contain only reasoning blocks. + // Keep reasoning in transcript cells, but only persist assistant turns that + // include visible text and/or tool calls. + let has_sendable_content = blocks.iter().any(|block| { + matches!( + block, + ContentBlock::Text { .. } | ContentBlock::ToolUse { .. } + ) + }); + if has_sendable_content { + app.api_messages.push(Message { + role: "assistant".to_string(), + content: blocks, + }); + } + } + EngineEvent::ThinkingStarted { .. } => { + // P2.3: thinking lives in the active cell so it groups + // visually with the tool calls that follow until the + // next assistant prose chunk flushes the group. + app.reasoning_buffer.clear(); + app.reasoning_header = None; + app.thinking_started_at = Some(Instant::now()); + app.streaming_state.reset(); + app.streaming_state.start_thinking(0, None); + let _ = ensure_streaming_thinking_active_entry(app); + } + EngineEvent::ThinkingDelta { content, .. } => { + let sanitized = sanitize_stream_chunk(&content); + if sanitized.is_empty() { + continue; + } + app.reasoning_buffer.push_str(&sanitized); + if app.reasoning_header.is_none() { + app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer); + } + + let entry_idx = ensure_streaming_thinking_active_entry(app); + app.streaming_state.push_content(0, &sanitized); + let committed = app.streaming_state.commit_text(0); + if !committed.is_empty() { + append_streaming_thinking(app, entry_idx, &committed); + transcript_batch_updated = true; + } + } + EngineEvent::ThinkingComplete { .. } => { + let duration = app + .thinking_started_at + .take() + .map(|t| t.elapsed().as_secs_f32()); + let remaining = app.streaming_state.finalize_block_text(0); + if finalize_streaming_thinking_active_entry(app, duration, &remaining) { + transcript_batch_updated = true; + } + + if !app.reasoning_buffer.is_empty() { + app.last_reasoning = Some(app.reasoning_buffer.clone()); + } + app.reasoning_buffer.clear(); + } + EngineEvent::ToolCallStarted { id, name, input } => { + app.pending_tool_uses + .push((id.clone(), name.clone(), input.clone())); + // Note this dispatch so the next sub-agent `Started` + // mailbox envelope routes into the right card kind + // (delegate vs fanout). + if matches!( + name.as_str(), + "agent_spawn" + | "agent_swarm" + | "spawn_agents_on_csv" + | "rlm" + | "delegate" + ) { + app.pending_subagent_dispatch = Some(name.clone()); + if matches!( + name.as_str(), + "agent_swarm" | "spawn_agents_on_csv" | "rlm" + ) { + // New fanout invocation — children should + // group under a fresh card, not the + // previous swarm's leftover. + app.last_fanout_card_index = None; + } + } + handle_tool_call_started(app, &id, &name, &input); + } + EngineEvent::ToolCallComplete { id, name, result } => { + if name == "update_plan" { + app.plan_tool_used_in_turn = true; + } + let tool_content = match &result { + Ok(output) => sanitize_stream_chunk( + &crate::core::engine::compact_tool_result_for_context( + &app.model, &name, output, + ), + ), + Err(err) => sanitize_stream_chunk(&format!("Error: {err}")), + }; + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::ToolResult { + tool_use_id: id.clone(), + content: tool_content, + is_error: None, + content_blocks: None, + }], + }); + handle_tool_call_complete(app, &id, &name, &result); + + // Immediately refresh the task panel sidebar when a + // tool that changes task state completes, so the + // Tasks panel stays in sync with tool execution + // rather than waiting up to 2.5 s for the periodic + // poll. + if matches!( + name.as_str(), + "agent_spawn" | "agent_swarm" | "agent_cancel" | "todo_write" + ) { + let tasks = task_manager.list_tasks(Some(10)).await; + app.task_panel = + tasks.into_iter().map(task_summary_to_panel_entry).collect(); + last_task_refresh = Instant::now(); + } + } + EngineEvent::TurnStarted { turn_id } => { + app.is_loading = true; + app.offline_mode = false; + current_streaming_text.clear(); + app.streaming_state.reset(); + app.streaming_message_index = None; + app.streaming_thinking_active_entry = None; + app.turn_started_at = Some(Instant::now()); + app.runtime_turn_id = Some(turn_id); + app.runtime_turn_status = Some("in_progress".to_string()); + app.reasoning_buffer.clear(); + app.reasoning_header = None; + app.last_reasoning = None; + app.pending_tool_uses.clear(); + app.plan_tool_used_in_turn = false; + persist_checkpoint(app); + last_status_frame = Instant::now(); + } + EngineEvent::TurnComplete { + usage, + status, + error, + } => { + // Finalize any in-flight tool group. Cancellation + // marks still-running entries as Failed so the user + // sees they were interrupted rather than the spinner + // hanging forever. + if matches!( + status, + crate::core::events::TurnOutcomeStatus::Interrupted + | crate::core::events::TurnOutcomeStatus::Failed + ) { + app.finalize_active_cell_as_interrupted(); + // Also mark the streaming Assistant cell (if any) + // so partial reasoning/text isn't left with a + // permanent spinner. Idempotent with the + // optimistic call in the Esc handler. + app.finalize_streaming_assistant_as_interrupted(); + } else { + app.flush_active_cell(); + } + app.is_loading = false; + app.offline_mode = false; + app.streaming_state.reset(); + // Capture elapsed before clearing turn_started_at so + // notifications can use the real wall-clock duration. + let turn_elapsed = + app.turn_started_at.map(|t| t.elapsed()).unwrap_or_default(); + app.turn_started_at = None; + // Stream lock applies per-turn; clear it so the next + // turn's chunks pull the view down again until the + // user opts out by scrolling up. + app.user_scrolled_during_stream = false; + app.runtime_turn_status = Some(match status { + crate::core::events::TurnOutcomeStatus::Completed => { + "completed".to_string() + } + crate::core::events::TurnOutcomeStatus::Interrupted => { + "interrupted".to_string() + } + crate::core::events::TurnOutcomeStatus::Failed => "failed".to_string(), + }); + let turn_tokens = usage.input_tokens + usage.output_tokens; + app.total_tokens = app.total_tokens.saturating_add(turn_tokens); + app.total_conversation_tokens = + app.total_conversation_tokens.saturating_add(turn_tokens); + app.last_prompt_tokens = Some(usage.input_tokens); + app.last_completion_tokens = Some(usage.output_tokens); + app.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens; + app.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens; + app.last_reasoning_replay_tokens = usage.reasoning_replay_tokens; + if let Some(error) = error { + app.status_message = Some(format!("Turn failed: {error}")); + } + + // Update session cost + let turn_cost = + crate::pricing::calculate_turn_cost_from_usage(&app.model, &usage); + if let Some(cost) = turn_cost { + app.session_cost += cost; + } + + // Emit OSC 9 / BEL desktop notification for long turns. + if status == crate::core::events::TurnOutcomeStatus::Completed { + let notif = config.notifications_config(); + let method = + crate::tui::notifications::Method::from_str(match ¬if.method { + crate::config::NotificationMethod::Auto => "auto", + crate::config::NotificationMethod::Osc9 => "osc9", + crate::config::NotificationMethod::Bel => "bel", + crate::config::NotificationMethod::Off => "off", + }); + let threshold = std::time::Duration::from_secs(notif.threshold_secs); + let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty()); + let msg = if notif.include_summary { + let human = + crate::tui::notifications::humanize_duration(turn_elapsed); + match turn_cost { + Some(c) => { + format!("deepseek: turn complete ({human}, ${c:.2})") + } + None => format!("deepseek: turn complete ({human})"), + } + } else { + "deepseek: turn complete".to_string() + }; + crate::tui::notifications::notify_done( + method, + in_tmux, + &msg, + threshold, + turn_elapsed, + ); + } + + // Auto-save completed turn and clear crash checkpoint. + persist_session_snapshot(app); + clear_checkpoint(); + + if app.mode == AppMode::Plan + && app.plan_tool_used_in_turn + && !app.plan_prompt_pending + && app.queued_message_count() == 0 + && app.queued_draft.is_none() + { + app.plan_prompt_pending = true; + app.add_message(HistoryCell::System { + content: plan_next_step_prompt(), + }); + if app.view_stack.top_kind() != Some(ModalKind::PlanPrompt) { + app.view_stack.push(PlanPromptView::new()); + } + } + app.plan_tool_used_in_turn = false; + + // Esc-to-steer (#122): the user interrupted with input + // pending. Merge every steered message into one fresh + // turn so the model sees a single coherent prompt. + if status == crate::core::events::TurnOutcomeStatus::Interrupted + && app.submit_pending_steers_after_interrupt + { + if let Some(merged) = merge_pending_steers(&mut *app) { + queued_to_send = Some(merged); + } + } else if status == crate::core::events::TurnOutcomeStatus::Failed + && !app.pending_steers.is_empty() + { + // Hard-fail recovery: if the engine failed before + // a clean Interrupted landed, demote pending + // steers to the visible queue so they're not + // silently lost. User can /queue to inspect. + for msg in app.drain_pending_steers() { + app.queue_message(msg); + } + } + + if queued_to_send.is_none() { + queued_to_send = app.pop_queued_message(); + } + } + EngineEvent::Error { + envelope, + recoverable: _, + } => { + apply_engine_error_to_app(app, envelope); + } + EngineEvent::Status { message } => { + app.status_message = Some(message); + } + EngineEvent::SessionUpdated { + messages, + system_prompt, + model, + workspace, + } => { + app.api_messages = messages; + app.system_prompt = system_prompt; + app.model = model; + app.update_model_compaction_budget(); + app.workspace = workspace; + if app.is_loading || app.is_compacting { + persist_checkpoint(app); + } + } + EngineEvent::CompactionStarted { message, .. } => { + app.is_compacting = true; + app.status_message = Some(message); + } + EngineEvent::CompactionCompleted { message, .. } => { + app.is_compacting = false; + app.status_message = Some(message); + } + EngineEvent::CompactionFailed { message, .. } => { + app.is_compacting = false; + app.status_message = Some(message); + } + EngineEvent::CycleAdvanced { from, to, briefing } => { + // Mirror the engine-side counter on the UI app state + // so the sidebar / slash commands stay in sync, and + // record the briefing so `/cycle ` can show it. + app.cycle_count = to; + let briefing_tokens = briefing.token_estimate; + app.cycle_briefings.push(briefing); + let separator = format!( + "─── cycle {from} → {to} (briefing: {briefing_tokens} tokens) ───" + ); + app.add_message(HistoryCell::System { content: separator }); + app.status_message = Some(format!( + "↻ context refreshed (cycle {from} → {to}, briefing: {briefing_tokens} tokens carried)" + )); + } + EngineEvent::CoherenceState { state, .. } => { + app.coherence_state = state; + } + EngineEvent::CapacityDecision { .. } => { + // Telemetry-only event. Surface actual interventions and failures + // instead of replacing the footer with no-op guardrail chatter. + } + EngineEvent::CapacityIntervention { + action, + before_prompt_tokens, + after_prompt_tokens, + .. + } => { + app.status_message = Some(format!( + "Capacity intervention: {action} (~{before_prompt_tokens} -> ~{after_prompt_tokens} tokens)" + )); + } + EngineEvent::CapacityMemoryPersistFailed { action, error, .. } => { + app.status_message = Some(format!( + "Capacity memory persist failed ({action}): {error}" + )); + } + EngineEvent::PauseEvents => { + if !event_broker.is_paused() { + pause_terminal( + terminal, + app.use_alt_screen, + app.use_mouse_capture, + app.use_bracketed_paste, + )?; + event_broker.pause_events(); + } + } + EngineEvent::ResumeEvents => { + if event_broker.is_paused() { + resume_terminal( + terminal, + app.use_alt_screen, + app.use_mouse_capture, + app.use_bracketed_paste, + )?; + event_broker.resume_events(); + } + } + EngineEvent::AgentSpawned { id, prompt } => { + let prompt_summary = summarize_tool_output(&prompt); + app.agent_progress + .insert(id.clone(), format!("starting: {prompt_summary}")); + if app.agent_activity_started_at.is_none() { + app.agent_activity_started_at = Some(Instant::now()); + } + app.status_message = + Some(format!("Sub-agent {id} starting: {prompt_summary}")); + let _ = engine_handle.send(Op::ListSubAgents).await; + } + EngineEvent::AgentProgress { id, status } => { + app.agent_progress + .insert(id.clone(), summarize_tool_output(&status)); + if app.agent_activity_started_at.is_none() { + app.agent_activity_started_at = Some(Instant::now()); + } + app.status_message = Some(format!("Sub-agent {id}: {status}")); + } + EngineEvent::AgentComplete { id, result } => { + app.agent_progress.remove(&id); + app.status_message = Some(format!( + "Sub-agent {id} completed: {}", + summarize_tool_output(&result) + )); + let _ = engine_handle.send(Op::ListSubAgents).await; + } + EngineEvent::AgentList { agents } => { + let mut sorted = agents.clone(); + sort_subagents_in_place(&mut sorted); + app.subagent_cache = sorted.clone(); + reconcile_subagent_activity_state(app); + if app.view_stack.update_subagents(&sorted) { + app.status_message = + Some(format!("Sub-agents: {} total", sorted.len())); + } + // Individual spawn/complete events already log to history; + // full list available via /agents command. + } + EngineEvent::SubAgentMailbox { seq, message } => { + handle_subagent_mailbox(app, seq, &message); + transcript_batch_updated = true; + } + EngineEvent::ApprovalRequired { + id, + tool_name, + description, + approval_key, + } => { + let session_approved = + app.approval_session_approved.contains(&approval_key) + || app.approval_session_approved.contains(&tool_name); + if session_approved || app.approval_mode == ApprovalMode::Auto { + log_sensitive_event( + "tool.approval.auto_approve", + serde_json::json!({ + "tool_name": tool_name, + "approval_key": approval_key, + "session_id": app.current_session_id, + "mode": app.mode.label(), + }), + ); + let _ = engine_handle.approve_tool_call(id.clone()).await; + } else if app.approval_mode == ApprovalMode::Never { + log_sensitive_event( + "tool.approval.auto_deny", + serde_json::json!({ + "tool_name": tool_name, + "session_id": app.current_session_id, + "mode": app.mode.label(), + }), + ); + let _ = engine_handle.deny_tool_call(id.clone()).await; + app.status_message = + Some(format!("Blocked tool '{tool_name}' (approval_mode=never)")); + } else { + let tool_input = app + .pending_tool_uses + .iter() + .find(|(tool_id, _, _)| tool_id == &id) + .map(|(_, _, input)| input.clone()) + .unwrap_or_else(|| serde_json::json!({})); + + if tool_name == "apply_patch" { + maybe_add_patch_preview(app, &tool_input); + } + + // Create approval request and show overlay + let request = ApprovalRequest::new( + &id, + &tool_name, + &description, + &tool_input, + &approval_key, + ); + log_sensitive_event( + "tool.approval.prompted", + serde_json::json!({ + "tool_name": tool_name, + "description": description, + "session_id": app.current_session_id, + "mode": app.mode.label(), + }), + ); + app.view_stack.push(ApprovalView::new(request)); + app.status_message = Some(format!( + "Approval required for '{tool_name}': {description}" + )); + } + } + EngineEvent::UserInputRequired { id, request } => { + app.view_stack.push(UserInputView::new(id.clone(), request)); + app.status_message = Some( + "Action required: answer the popup with 1-4, arrows, or Enter" + .to_string(), + ); + } + EngineEvent::ToolCallProgress { id, output } => { + app.status_message = + Some(format!("Tool {id}: {}", summarize_tool_output(&output))); + } + EngineEvent::ElevationRequired { + tool_id, + tool_name, + command, + denial_reason, + blocked_network, + blocked_write, + } => { + // In YOLO mode, auto-elevate to full access + if app.approval_mode == ApprovalMode::Auto { + log_sensitive_event( + "tool.sandbox.auto_elevate", + serde_json::json!({ + "tool_name": tool_name, + "tool_id": tool_id, + "reason": denial_reason, + "session_id": app.current_session_id, + }), + ); + app.add_message(HistoryCell::System { + content: format!( + "Sandbox denied {tool_name}: {denial_reason} - auto-elevating to full access" + ), + }); + // Auto-elevate to full access (no sandbox) + let policy = crate::sandbox::SandboxPolicy::DangerFullAccess; + let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; + } else { + log_sensitive_event( + "tool.sandbox.prompt_elevation", + serde_json::json!({ + "tool_name": tool_name, + "tool_id": tool_id, + "reason": denial_reason, + "session_id": app.current_session_id, + }), + ); + // Show elevation dialog + let request = ElevationRequest::for_shell( + &tool_id, + command.as_deref().unwrap_or(&tool_name), + &denial_reason, + blocked_network, + blocked_write, + ); + app.view_stack.push(ElevationView::new(request)); + app.status_message = + Some(format!("Sandbox blocked {tool_name}: {denial_reason}")); + } + } + } + } + } + if transcript_batch_updated { + app.mark_history_updated(); + } + if received_engine_event { + app.needs_redraw = true; + } + + if let Some(next) = queued_to_send { + if let Err(err) = dispatch_user_message(app, &engine_handle, next.clone()).await { + app.queue_message(next); + app.status_message = Some(format!( + "Dispatch failed ({err}); kept {} queued message(s)", + app.queued_message_count() + )); + } + + app.needs_redraw = true; + } + + let queue_state = (app.queued_messages.clone(), app.queued_draft.clone()); + if queue_state != last_queue_state { + persist_offline_queue_state(app); + last_queue_state = queue_state; + app.needs_redraw = true; + } + + if !app.view_stack.is_empty() { + let events = app.view_stack.tick(); + if !events.is_empty() { + app.needs_redraw = true; + } + if handle_view_events(app, config, &task_manager, &mut engine_handle, events).await? { + return Ok(()); + } + } + + let has_running_agents = running_agent_count(app) > 0; + if (app.is_loading || has_running_agents || app.is_compacting) + && last_status_frame.elapsed() + >= Duration::from_millis(status_animation_interval_ms(app)) + { + if !app.low_motion && history_has_live_motion(&app.history) { + app.mark_history_updated(); + } + app.needs_redraw = true; + last_status_frame = Instant::now(); + } + + if event_broker.is_paused() { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + continue; + } + + let now = Instant::now(); + app.flush_paste_burst_if_due(now); + app.sync_status_message_to_toasts(); + // Expire the "Press Ctrl+C again to quit" prompt silently after its + // window. Triggers a redraw if the prompt was visible. + app.tick_quit_armed(); + let allow_workspace_context_refresh = + !app.is_loading && !has_running_agents && !app.is_compacting; + refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh); + + // Draw is gated by the frame-rate limiter (120 FPS cap). When a + // redraw is needed but the limiter says we're inside the cooldown + // window, leave `needs_redraw = true` and shorten the poll timeout + // so the loop wakes up exactly when drawing is allowed. + + // Sync low-motion flag into the frame-rate limiter and streaming + // chunking policy. Low-motion mode drops the frame cap to 30 FPS + // and forces Smooth-only chunking so the display stays calm. + frame_rate_limiter.set_low_motion(app.low_motion); + app.streaming_state.set_low_motion(app.low_motion); + + let draw_wait = if app.needs_redraw { + frame_rate_limiter.time_until_next_draw(now) + } else { + None + }; + if app.needs_redraw && draw_wait.is_none() { + terminal.draw(|f| render(f, app))?; // app is &mut + frame_rate_limiter.mark_emitted(Instant::now()); + app.needs_redraw = false; + } + + let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting { + Duration::from_millis(active_poll_ms(app)) + } else { + Duration::from_millis(idle_poll_ms(app)) + }; + if let Some(until_flush) = app.paste_burst.next_flush_delay(now) { + poll_timeout = poll_timeout.min(until_flush); + } + if let Some(until_draw) = draw_wait { + poll_timeout = poll_timeout.min(until_draw); + } + // While the quit-confirmation prompt is armed, ensure we wake up to + // expire it on time even if no input event arrives. + if let Some(deadline) = app.quit_armed_until { + let remaining = deadline.saturating_duration_since(now); + poll_timeout = poll_timeout.min(remaining.max(Duration::from_millis(50))); + } + if event::poll(poll_timeout)? { + let evt = event::read()?; + app.needs_redraw = true; + + // Handle bracketed paste events + if let Event::Paste(text) = &evt { + tracing::debug!( + paste_len = text.len(), + preview = %text.chars().take(80).collect::(), + "Received bracketed paste event" + ); + if app.onboarding == OnboardingState::ApiKey { + // Paste into API key input + app.insert_api_key_str(text); + sync_api_key_validation_status(app, false); + } else { + // Paste into main input + if let Some(pending) = app.paste_burst.flush_before_modified_input() { + app.insert_str(&pending); + } + app.insert_paste_text(text); + } + continue; + } + + if let Event::Resize(width, height) = evt { + tracing::debug!(width, height, "Event::Resize received; clearing terminal"); + // Drain any further Resize events queued in this poll cycle so we + // act on the final size only, then issue a single clear + redraw. + // crossterm coalesces some resize events but rapid drag-resizes + // can still queue several; processing them all here avoids the + // common "stale art on the right edge" symptom (#65) caused by + // the diff renderer skipping cells that match a stale back + // buffer between intermediate sizes. + let mut final_w = width; + let mut final_h = height; + while event::poll(Duration::from_millis(0)).unwrap_or(false) { + match event::read() { + Ok(Event::Resize(w, h)) => { + final_w = w; + final_h = h; + } + Ok(other) => { + // Non-resize event during the drain: we can't + // un-read it. Drop it and let the user re-issue + // — the resize-coalesce window is tiny. + tracing::debug!( + ?other, + "non-resize event during resize coalesce; dropping" + ); + break; + } + Err(_) => break, + } + } + terminal.clear()?; + app.handle_resize(final_w, final_h); + // Draw immediately so the cleared screen gets repainted before + // any other events can interleave. Without this, the next + // iteration's draw can race against fast follow-up input and + // leave the user staring at a blank/partial frame. + terminal.draw(|f| render(f, app))?; + app.needs_redraw = false; + continue; + } + + if app.use_mouse_capture + && let Event::Mouse(mouse) = evt + { + handle_mouse_event(app, mouse); + continue; + } + + let Event::Key(key) = evt else { + continue; + }; + + if key.kind != KeyEventKind::Press { + continue; + } + + // Handle onboarding flow + if app.onboarding != OnboardingState::None { + let advance_onboarding = |app: &mut App| { + app.status_message = None; + if app.onboarding_needs_api_key { + app.onboarding = OnboardingState::ApiKey; + } else if !app.trust_mode && onboarding::needs_trust(&app.workspace) { + app.onboarding = OnboardingState::TrustDirectory; + } else { + app.onboarding = OnboardingState::Tips; + } + }; + + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let _ = engine_handle.send(Op::Shutdown).await; + return Ok(()); + } + KeyCode::Esc if app.onboarding == OnboardingState::ApiKey => { + app.onboarding = OnboardingState::Welcome; + app.api_key_input.clear(); + app.api_key_cursor = 0; + app.status_message = None; + } + KeyCode::Enter => match app.onboarding { + OnboardingState::Welcome => { + advance_onboarding(app); + } + OnboardingState::ApiKey => { + let key = app.api_key_input.trim().to_string(); + if let ApiKeyValidation::Reject(message) = + validate_api_key_for_onboarding(&key) + { + app.status_message = Some(message); + continue; + } + match app.submit_api_key() { + Ok(_) => { + app.status_message = None; + // Recreate the engine so it picks up the newly saved key + // without requiring a full process restart. + let _ = engine_handle.send(Op::Shutdown).await; + let mut refreshed_config = config.clone(); + refreshed_config.api_key = Some(key); + let engine_config = build_engine_config(app, &refreshed_config); + engine_handle = spawn_engine(engine_config, &refreshed_config); + + if !app.api_messages.is_empty() { + let _ = engine_handle + .send(Op::SyncSession { + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + }) + .await; + } + + advance_onboarding(app); + } + Err(e) => { + app.status_message = Some(e.to_string()); + } + } + } + OnboardingState::TrustDirectory => {} + OnboardingState::Tips => { + app.finish_onboarding(); + } + OnboardingState::None => {} + }, + KeyCode::Char('y') | KeyCode::Char('Y') + if app.onboarding == OnboardingState::TrustDirectory => + { + match onboarding::mark_trusted(&app.workspace) { + Ok(_) => { + app.trust_mode = true; + app.status_message = None; + app.onboarding = OnboardingState::Tips; + } + Err(err) => { + app.status_message = + Some(format!("Failed to trust workspace: {err}")); + } + } + } + KeyCode::Char('n') | KeyCode::Char('N') + if app.onboarding == OnboardingState::TrustDirectory => + { + app.status_message = None; + app.onboarding = OnboardingState::Tips; + } + KeyCode::Backspace if app.onboarding == OnboardingState::ApiKey => { + app.delete_api_key_char(); + sync_api_key_validation_status(app, false); + } + KeyCode::Char(c) if app.onboarding == OnboardingState::ApiKey => { + app.insert_api_key_char(c); + sync_api_key_validation_status(app, false); + } + KeyCode::Char('v') | KeyCode::Char('V') + if is_paste_shortcut(&key) && app.onboarding == OnboardingState::ApiKey => + { + // Cmd+V / Ctrl+V paste (bracketed paste handled above) + app.paste_api_key_from_clipboard(); + sync_api_key_validation_status(app, false); + } + _ => {} + } + continue; + } + + if key.code == KeyCode::F(1) { + if app.view_stack.top_kind() == Some(ModalKind::Help) { + app.view_stack.pop(); + } else { + app.view_stack.push(HelpView::new()); + } + continue; + } + + if key.code == KeyCode::Char('/') && key.modifiers.contains(KeyModifiers::CONTROL) { + if app.view_stack.top_kind() == Some(ModalKind::Help) { + app.view_stack.pop(); + } else { + app.view_stack.push(HelpView::new()); + } + continue; + } + + if key.code == KeyCode::Char('k') && key.modifiers.contains(KeyModifiers::CONTROL) { + // When the composer is the active input target (no modal/pager + // intercepting keys), Ctrl+K performs an emacs-style kill to + // end-of-line. If the kill is a no-op (cursor at end of empty + // input), fall through to the existing command palette. + if app.view_stack.is_empty() && app.kill_to_end_of_line() { + continue; + } + app.view_stack + .push(CommandPaletteView::new(build_command_palette_entries( + &app.skills_dir, + &app.workspace, + ))); + continue; + } + + // Ctrl+P opens the fuzzy file-picker overlay. Bound only when the + // composer is focused (no other modal on top of the stack) and the + // engine is not actively streaming a turn. + if key.code == KeyCode::Char('p') + && key.modifiers.contains(KeyModifiers::CONTROL) + && app.view_stack.is_empty() + && !app.is_loading + { + open_file_picker(app); + continue; + } + + if matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) + && key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::SUPER) + && app.view_stack.is_empty() + { + open_context_inspector(app); + continue; + } + + if !app.view_stack.is_empty() { + let events = app.view_stack.handle_key(key); + if handle_view_events(app, config, &task_manager, &mut engine_handle, events) + .await? + { + return Ok(()); + } + continue; + } + + let now = Instant::now(); + app.flush_paste_burst_if_due(now); + + // On Windows, AltGr is delivered as `Ctrl+Alt`; treat + // AltGr-typed chars (e.g. European layouts producing `@`, `\`, + // `|`) as plain text rather than swallowing them as a modified + // shortcut. `key_hint::has_ctrl_or_alt` filters AltGr out. + let has_ctrl_alt_or_super = super::widgets::key_hint::has_ctrl_or_alt(key.modifiers) + || key.modifiers.contains(KeyModifiers::SUPER); + let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super; + let is_enter = matches!(key.code, KeyCode::Enter); + + if !is_plain_char + && !is_enter + && let Some(pending) = app.paste_burst.flush_before_modified_input() + { + app.insert_str(&pending); + } + + if (is_plain_char || is_enter) && super::paste::handle_paste_burst_key(app, &key, now) { + continue; + } + + let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT); + let slash_menu_open = !slash_menu_entries.is_empty(); + if slash_menu_open && app.slash_menu_selected >= slash_menu_entries.len() { + app.slash_menu_selected = slash_menu_entries.len().saturating_sub(1); + } + let mention_menu_entries = + crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT); + let mention_menu_open = !mention_menu_entries.is_empty(); + if mention_menu_open && app.mention_menu_selected >= mention_menu_entries.len() { + app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1); + } + + // Cancel a pending Esc-Esc prime as soon as any non-Esc key + // arrives. Without this the prime would hang around for the + // rest of the session and the user's next genuine Esc would + // suddenly skip straight into the backtrack overlay. + if !matches!(key.code, KeyCode::Esc) + && matches!( + app.backtrack.phase, + crate::tui::backtrack::BacktrackPhase::Primed + ) + { + app.backtrack.reset(); + } + + // Global keybindings + match key.code { + KeyCode::Enter + if app.input.is_empty() + && app.transcript_selection.is_active() + && open_pager_for_selection(app) => + { + continue; + } + KeyCode::Char('l') + if key.modifiers.is_empty() + && app.input.is_empty() + && open_pager_for_last_message(app) => + { + continue; + } + KeyCode::Char('v') | KeyCode::Char('V') + if details_shortcut_modifiers(key.modifiers) + && app.input.is_empty() + && open_tool_details_pager(app) => + { + continue; + } + KeyCode::Char('o') + if key.modifiers == KeyModifiers::CONTROL && open_thinking_pager(app) => + { + continue; + } + KeyCode::Char('t') | KeyCode::Char('T') + if key.modifiers == KeyModifiers::CONTROL => + { + toggle_live_transcript_overlay(app); + continue; + } + KeyCode::Char('1') if key.modifiers.contains(KeyModifiers::ALT) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Plan); + app.status_message = Some("Sidebar focus: plan".to_string()); + } else { + app.set_mode(AppMode::Plan); + } + continue; + } + KeyCode::Char('2') if key.modifiers.contains(KeyModifiers::ALT) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Todos); + app.status_message = Some("Sidebar focus: todos".to_string()); + } else { + app.set_mode(AppMode::Agent); + } + continue; + } + KeyCode::Char('3') if key.modifiers.contains(KeyModifiers::ALT) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Tasks); + app.status_message = Some("Sidebar focus: tasks".to_string()); + } else { + app.set_mode(AppMode::Yolo); + } + continue; + } + KeyCode::Char('4') if key.modifiers.contains(KeyModifiers::ALT) => { + apply_alt_4_shortcut(app, key.modifiers); + continue; + } + KeyCode::Char('!') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Plan); + app.status_message = Some("Sidebar focus: plan".to_string()); + continue; + } + KeyCode::Char('@') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Todos); + app.status_message = Some("Sidebar focus: todos".to_string()); + continue; + } + KeyCode::Char('#') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Tasks); + app.status_message = Some("Sidebar focus: tasks".to_string()); + continue; + } + KeyCode::Char('$') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Agents); + app.status_message = Some("Sidebar focus: agents".to_string()); + continue; + } + KeyCode::Char(')') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Auto); + app.status_message = Some("Sidebar focus: auto".to_string()); + continue; + } + KeyCode::Char('0') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Auto); + app.status_message = Some("Sidebar focus: auto".to_string()); + continue; + } + KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.view_stack.push(SessionPickerView::new()); + continue; + } + KeyCode::Char('c') | KeyCode::Char('C') + if key.modifiers.contains(KeyModifiers::CONTROL) + && app.transcript_selection.is_active() => + { + copy_active_selection(app); + } + KeyCode::Char('c') | KeyCode::Char('C') if is_copy_shortcut(&key) => { + copy_active_selection(app); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Three behaviors layered on Ctrl+C, in priority order: + // 1. While a turn is in flight, cancel it (unchanged). + // 2. Otherwise, on the first press, arm a 2-second + // "press Ctrl+C again to quit" prompt and stay + // running. + // 3. On the second press while still armed, exit cleanly. + // The prompt expires silently after the window so a + // stray Ctrl+C three seconds later re-arms instead of + // accidentally exiting. + if app.is_loading { + engine_handle.cancel(); + app.is_loading = false; + app.streaming_state.reset(); + // Optimistically clear the turn-in-progress flag so + // the footer wave animation halts immediately — + // without this, the strip keeps animating until the + // engine eventually emits TurnComplete (#5a). The + // engine's eventual TurnComplete event will overwrite + // with the real outcome ("interrupted"). + app.runtime_turn_status = None; + app.status_message = Some("Request cancelled".to_string()); + app.disarm_quit(); + } else if app.quit_is_armed() { + let _ = engine_handle.send(Op::Shutdown).await; + return Ok(()); + } else { + app.arm_quit(); + } + } + KeyCode::Char('d') + if key.modifiers.contains(KeyModifiers::CONTROL) && app.input.is_empty() => + { + let _ = engine_handle.send(Op::Shutdown).await; + return Ok(()); + } + KeyCode::Esc if mention_menu_open => { + app.mention_menu_hidden = true; + app.mention_menu_selected = 0; + } + KeyCode::Esc => match next_escape_action(app, slash_menu_open) { + EscapeAction::CloseSlashMenu => { + // A popup-style action wins over backtrack — clear + // any prime so a stale Primed state can't jump us + // straight into Selecting on the next Esc. + app.backtrack.reset(); + app.close_slash_menu(); + } + EscapeAction::CancelRequest => { + app.backtrack.reset(); + engine_handle.cancel(); + app.is_loading = false; + app.streaming_state.reset(); + // Optimistically halt the wave + working label — + // engine's TurnComplete will resync with the real + // outcome. Fixes #5a (wave kept animating after Esc). + app.runtime_turn_status = None; + app.finalize_streaming_assistant_as_interrupted(); + app.status_message = Some("Request cancelled".to_string()); + } + EscapeAction::SteerAndAbort => { + app.backtrack.reset(); + if let Some(input) = app.submit_input() { + let queued = build_queued_message(app, input); + app.push_pending_steer(queued); + engine_handle.cancel(); + app.is_loading = false; + app.streaming_state.reset(); + app.runtime_turn_status = None; + app.finalize_streaming_assistant_as_interrupted(); + let count = app.pending_steers.len(); + app.status_message = Some(if count == 1 { + "Steering: aborting turn and resending input".to_string() + } else { + format!("Steering: aborting turn and resending {count} input(s)") + }); + } + } + EscapeAction::DiscardQueuedDraft => { + app.backtrack.reset(); + app.queued_draft = None; + app.status_message = Some("Stopped editing queued message".to_string()); + } + EscapeAction::ClearInput => { + app.backtrack.reset(); + app.clear_input(); + } + EscapeAction::Noop => { + // Nothing else cares about this Esc — route it + // through the backtrack state machine. While + // streaming or with the live transcript already + // open, fall through silently (#133 acceptance: + // "during streaming Esc-Esc is a silent no-op"). + if app.is_loading + || app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) + { + continue; + } + let total = count_user_history_cells(app); + match app.backtrack.handle_esc(total) { + crate::tui::backtrack::EscEffect::None => {} + crate::tui::backtrack::EscEffect::Prime => { + app.status_message = + Some("Press Esc again to backtrack".to_string()); + app.needs_redraw = true; + } + crate::tui::backtrack::EscEffect::Cancel => { + app.status_message = Some("Backtrack canceled".to_string()); + app.needs_redraw = true; + } + crate::tui::backtrack::EscEffect::OpenOverlay => { + open_backtrack_overlay(app); + } + } + } + }, + // #85: Alt+↑ pops the most-recent queued message back into the + // composer for editing when the preview's affordance is visible + // (queue non-empty, composer idle). Splits the binding into two + // arms so the legacy scroll fallback is unambiguous on the same + // chord. + KeyCode::Up + if key.modifiers.contains(KeyModifiers::ALT) + && app.input.is_empty() + && app.queued_draft.is_none() + && !app.queued_messages.is_empty() => + { + let _ = app.pop_last_queued_into_draft(); + } + KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { + app.scroll_up(3); + } + KeyCode::Up + if key.modifiers.is_empty() + && mention_menu_open + && app.mention_menu_selected > 0 => + { + app.mention_menu_selected = app.mention_menu_selected.saturating_sub(1); + } + KeyCode::Up + if key.modifiers.is_empty() + && slash_menu_open + && app.slash_menu_selected > 0 => + { + app.slash_menu_selected = app.slash_menu_selected.saturating_sub(1); + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::ALT) => { + app.scroll_down(3); + } + KeyCode::Down if key.modifiers.is_empty() && mention_menu_open => { + app.mention_menu_selected = (app.mention_menu_selected + 1) + .min(mention_menu_entries.len().saturating_sub(1)); + } + KeyCode::Down if key.modifiers.is_empty() && slash_menu_open => { + app.slash_menu_selected = (app.slash_menu_selected + 1) + .min(slash_menu_entries.len().saturating_sub(1)); + } + KeyCode::PageUp => { + let page = app.last_transcript_visible.max(1); + app.scroll_up(page); + } + KeyCode::PageDown => { + let page = app.last_transcript_visible.max(1); + app.scroll_down(page); + } + KeyCode::Tab => { + if mention_menu_open + && crate::tui::file_mention::apply_mention_menu_selection( + app, + &mention_menu_entries, + ) + { + continue; + } + if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, true) + { + continue; + } + if try_autocomplete_slash_command(app) { + continue; + } + if crate::tui::file_mention::try_autocomplete_file_mention(app) { + continue; + } + let prior_model = app.model.clone(); + app.cycle_mode(); + if app.model != prior_model { + let _ = engine_handle + .send(Op::SetModel { + model: app.model.clone(), + }) + .await; + } + } + KeyCode::BackTab => { + app.cycle_effort(); + } + KeyCode::Char('g') + if key.modifiers.is_empty() && app.input.is_empty() && !slash_menu_open => + { + if let Some(anchor) = + TranscriptScroll::anchor_for(app.transcript_cache.line_meta(), 0) + { + app.transcript_scroll = anchor; + } + } + KeyCode::Char('G') + if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) + && app.input.is_empty() + && !slash_menu_open => + { + app.scroll_to_bottom(); + } + KeyCode::Char('[') + if key.modifiers.is_empty() + && app.input.is_empty() + && !slash_menu_open + && !jump_to_adjacent_tool_cell(app, SearchDirection::Backward) => + { + app.status_message = Some("No previous tool output".to_string()); + } + KeyCode::Char(']') + if key.modifiers.is_empty() + && app.input.is_empty() + && !slash_menu_open + && !jump_to_adjacent_tool_cell(app, SearchDirection::Forward) => + { + app.status_message = Some("No next tool output".to_string()); + } + // `?` opens the searchable help overlay (#93). Gated on the + // composer being empty so typing `?` mid-question is treated + // as text. `Shift` is permitted because US layouts produce + // `?` as `Shift+/`. Help-modal toggling lives next to the + // F1 / Ctrl+/ branch above; here we only open. + KeyCode::Char('?') + if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) + && app.input.is_empty() + && !slash_menu_open => + { + if app.view_stack.top_kind() != Some(ModalKind::Help) { + app.view_stack.push(HelpView::new()); + } + continue; + } + // Input handling + KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.insert_char('\n'); + } + KeyCode::Enter if key.modifiers.contains(KeyModifiers::ALT) => { + app.insert_char('\n'); + } + KeyCode::Enter + if mention_menu_open + && crate::tui::file_mention::apply_mention_menu_selection( + app, + &mention_menu_entries, + ) => + { + continue; + } + KeyCode::Enter => { + if let Some(input) = app.submit_input() { + if handle_plan_choice(app, &engine_handle, &input).await? { + continue; + } + if input.starts_with('/') { + if execute_command_input( + app, + &mut engine_handle, + &task_manager, + config, + &input, + ) + .await? + { + return Ok(()); + } + } else { + let queued = if let Some(mut draft) = app.queued_draft.take() { + draft.display = input; + draft + } else { + build_queued_message(app, input) + }; + submit_or_steer_message(app, &engine_handle, queued).await?; + } + } + } + KeyCode::Backspace => { + app.delete_char(); + } + KeyCode::Delete => { + app.delete_char_forward(); + } + KeyCode::Left => { + app.move_cursor_left(); + } + KeyCode::Right => { + app.move_cursor_right(); + } + KeyCode::Home if key.modifiers.is_empty() => { + if let Some(anchor) = + TranscriptScroll::anchor_for(app.transcript_cache.line_meta(), 0) + { + app.transcript_scroll = anchor; + } + } + KeyCode::End if key.modifiers.is_empty() => { + app.scroll_to_bottom(); + } + KeyCode::Home | KeyCode::Char('a') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + app.move_cursor_start(); + } + KeyCode::End => { + app.move_cursor_end(); + } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Ctrl+E: spawn $EDITOR on the composer contents (#91). + // Only fires when no modal is active (the !view_stack + // branch above already returns early in that case) and + // the composer is the focused input target. We accept the + // shortcut whether or not a model turn is streaming — + // editing the buffer never disturbs in-flight work. + let seed = app.input.clone(); + match super::external_editor::spawn_editor_for_input( + terminal, + app.use_alt_screen, + app.use_mouse_capture, + app.use_bracketed_paste, + &seed, + ) { + Ok(super::external_editor::EditorOutcome::Edited(new)) => { + app.input = new; + app.move_cursor_end(); + let editor = std::env::var("VISUAL") + .ok() + .filter(|s| !s.trim().is_empty()) + .or_else(|| { + std::env::var("EDITOR") + .ok() + .filter(|s| !s.trim().is_empty()) + }) + .unwrap_or_else(|| "vi".to_string()); + app.status_message = Some(format!("Edited in {editor}")); + } + Ok(super::external_editor::EditorOutcome::Unchanged) => { + app.status_message = Some("Editor closed (no changes)".to_string()); + } + Ok(super::external_editor::EditorOutcome::Cancelled) => { + app.status_message = Some("Editor cancelled".to_string()); + } + Err(err) => { + app.status_message = Some(format!("Editor error: {err}")); + } + } + app.needs_redraw = true; + } + KeyCode::Up => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + app.history_up(); + } else if should_scroll_with_arrows(app) { + app.scroll_up(1); + } else { + app.history_up(); + } + } + KeyCode::Down => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + app.history_down(); + } else if should_scroll_with_arrows(app) { + app.scroll_down(1); + } else { + app.history_down(); + } + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_input(); + } + KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Emacs-style yank from the kill buffer at the cursor. + // No-op when the buffer is empty. + app.yank(); + } + KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let new_mode = match app.mode { + AppMode::Plan => AppMode::Agent, + _ => AppMode::Plan, + }; + app.set_mode(new_mode); + } + KeyCode::Char('v') if is_paste_shortcut(&key) => { + app.paste_from_clipboard(); + } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Agent); + continue; + } + KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Yolo); + continue; + } + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Plan); + continue; + } + KeyCode::Char('A') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Agent); + continue; + } + KeyCode::Char('Y') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Yolo); + continue; + } + KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_mode(AppMode::Plan); + continue; + } + KeyCode::Char('v') | KeyCode::Char('V') + if key.modifiers.contains(KeyModifiers::ALT) => + { + open_tool_details_pager(app); + continue; + } + KeyCode::Char(c) => { + app.insert_char(c); + } + _ => {} + } + + if !is_plain_char && !is_enter { + app.paste_burst.clear_window_after_non_char(); + } + } + } +} + +fn apply_alt_4_shortcut(app: &mut App, modifiers: KeyModifiers) { + if modifiers.contains(KeyModifiers::CONTROL) { + app.set_sidebar_focus(SidebarFocus::Agents); + app.status_message = Some("Sidebar focus: agents".to_string()); + } else { + app.set_mode(AppMode::Plan); + } +} + +async fn fetch_available_models(config: &Config) -> Result> { + use crate::client::DeepSeekClient; + + let client = DeepSeekClient::new(config)?; + let models = tokio::time::timeout(Duration::from_secs(20), client.list_models()).await??; + let mut ids = models.into_iter().map(|model| model.id).collect::>(); + ids.sort(); + ids.dedup(); + Ok(ids) +} + +fn format_available_models_message(current_model: &str, models: &[String]) -> String { + let mut lines = vec![format!("Available models ({})", models.len())]; + for model in models { + if model == current_model { + lines.push(format!("* {model} (current)")); + } else { + lines.push(format!(" {model}")); + } + } + lines.join("\n") +} + +fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { + if let Some(ref existing_id) = app.current_session_id + && let Ok(existing) = manager.load_session(existing_id) + { + let mut updated = update_session( + existing, + &app.api_messages, + u64::from(app.total_tokens), + app.system_prompt.as_ref(), + ); + updated.metadata.mode = Some(app.mode.as_setting().to_string()); + updated.context_references = app.session_context_references.clone(); + updated + } else { + let mut session = create_saved_session_with_mode( + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.as_setting()), + ); + session.context_references = app.session_context_references.clone(); + session + } +} + +fn persist_session_snapshot(app: &mut App) { + if let Ok(manager) = SessionManager::default_location() { + let session = build_session_snapshot(app, &manager); + if let Err(err) = manager.save_session(&session) { + eprintln!("Failed to save session: {err}"); + } else { + app.current_session_id = Some(session.metadata.id.clone()); + } + } +} + +fn persist_checkpoint(app: &mut App) { + if let Ok(manager) = SessionManager::default_location() { + let session = build_session_snapshot(app, &manager); + if let Err(err) = manager.save_checkpoint(&session) { + eprintln!("Failed to save checkpoint: {err}"); + } + } +} + +fn clear_checkpoint() { + if let Ok(manager) = SessionManager::default_location() { + let _ = manager.clear_checkpoint(); + } +} + +fn queued_ui_to_session(msg: &QueuedMessage) -> QueuedSessionMessage { + QueuedSessionMessage { + display: msg.display.clone(), + skill_instruction: msg.skill_instruction.clone(), + } +} + +fn queued_session_to_ui(msg: QueuedSessionMessage) -> QueuedMessage { + QueuedMessage { + display: msg.display, + skill_instruction: msg.skill_instruction, + } +} + +/// Translate an `EngineEvent::Error` into UI state updates. +/// +/// The engine's `recoverable` flag (mirrored on `ErrorEnvelope`) decides +/// whether the session flips into offline mode: stream stalls, chunk +/// timeouts, transient network errors, and rate-limit/server hiccups arrive +/// recoverable and must NOT flip into offline. Hard failures (auth, billing, +/// invalid request) arrive non-recoverable; those flip offline so subsequent +/// messages get queued instead of silently lost mid-flight. +/// +/// `severity` drives transcript color: red for `Error`/`Critical`, amber for +/// `Warning`, dim for `Info`. +pub(crate) fn apply_engine_error_to_app( + app: &mut App, + envelope: crate::error_taxonomy::ErrorEnvelope, +) { + let recoverable = envelope.recoverable; + let message = envelope.message.clone(); + let severity = envelope.severity; + app.streaming_state.reset(); + app.streaming_message_index = None; + app.streaming_thinking_active_entry = None; + app.add_message(HistoryCell::Error { + message: message.clone(), + severity, + }); + app.is_loading = false; + if recoverable { + app.status_message = Some(format!("Connection interrupted: {message}")); + } else { + app.offline_mode = true; + app.status_message = Some(format!( + "Engine error; queued messages stay pending: {message}" + )); + } +} + +fn persist_offline_queue_state(app: &App) { + if let Ok(manager) = SessionManager::default_location() { + if app.queued_messages.is_empty() && app.queued_draft.is_none() { + let _ = manager.clear_offline_queue_state(); + return; + } + let state = OfflineQueueState { + messages: app + .queued_messages + .iter() + .map(queued_ui_to_session) + .collect(), + draft: app.queued_draft.as_ref().map(queued_ui_to_session), + ..OfflineQueueState::default() + }; + let _ = manager.save_offline_queue_state(&state); + } +} + +fn sanitize_stream_chunk(chunk: &str) -> String { + // Keep printable characters and common whitespace; drop control bytes. + chunk + .chars() + .filter(|c| *c == '\n' || *c == '\t' || !c.is_control()) + .collect() +} + +/// Ensure an in-flight streaming Assistant cell exists in history and return +/// its index. Thinking cells go through `ensure_streaming_thinking_active_entry` +/// (active cell) instead. +fn ensure_streaming_assistant_history_cell(app: &mut App) -> usize { + if let Some(index) = app.streaming_message_index { + return index; + } + app.add_message(HistoryCell::Assistant { + content: String::new(), + streaming: true, + }); + let index = app.history.len().saturating_sub(1); + app.streaming_message_index = Some(index); + index +} + +fn append_streaming_text(app: &mut App, index: usize, text: &str) { + if text.is_empty() { + return; + } + if let Some(HistoryCell::Assistant { content, .. }) = app.history.get_mut(index) { + content.push_str(text); + // Bump only the streaming cell's per-cell revision so the transcript + // cache re-renders just this cell. Without this, the cache would + // either skip the update entirely (now that the global + // history_version is no longer fanned out across every cell) or fall + // back to a full re-wrap of the entire transcript every chunk. + app.bump_history_cell(index); + } +} + +/// Ensure an in-flight Thinking entry exists in `active_cell` and return its +/// entry index. If no thinking entry is currently streaming, push a fresh one. +/// P2.3: thinking shares the active cell with subsequent tool calls so the +/// pair render as one logical "Working…" block. +fn ensure_streaming_thinking_active_entry(app: &mut App) -> usize { + if let Some(idx) = app.streaming_thinking_active_entry { + return idx; + } + if app.active_cell.is_none() { + app.active_cell = Some(ActiveCell::new()); + } + let active = app.active_cell.as_mut().expect("active_cell just ensured"); + let entry_idx = active.push_thinking(HistoryCell::Thinking { + content: String::new(), + streaming: true, + duration_secs: None, + }); + app.streaming_thinking_active_entry = Some(entry_idx); + app.bump_active_cell_revision(); + entry_idx +} + +/// Append text to a streaming Thinking entry inside `active_cell`. Bumps the +/// active-cell revision so the renderer re-draws the live tail. +fn append_streaming_thinking(app: &mut App, entry_idx: usize, text: &str) { + if text.is_empty() { + return; + } + let mutated = if let Some(active) = app.active_cell.as_mut() + && let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx) + { + content.push_str(text); + true + } else { + false + }; + if mutated { + app.bump_active_cell_revision(); + } +} + +/// Finalize the in-flight thinking entry in `active_cell`: append the +/// collector's remaining buffered text, stop the spinner, and stamp the +/// duration. Returns `true` when a thinking entry was finalized (so the +/// dispatch loop knows the transcript was touched). No-op if no thinking +/// entry is currently streaming. +fn finalize_streaming_thinking_active_entry( + app: &mut App, + duration: Option, + remaining: &str, +) -> bool { + let Some(entry_idx) = app.streaming_thinking_active_entry.take() else { + return false; + }; + if !remaining.is_empty() { + append_streaming_thinking(app, entry_idx, remaining); + } + if let Some(active) = app.active_cell.as_mut() + && let Some(HistoryCell::Thinking { + streaming, + duration_secs, + .. + }) = active.entry_mut(entry_idx) + { + *streaming = false; + *duration_secs = duration; + } + app.bump_active_cell_revision(); + true +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EscapeAction { + CloseSlashMenu, + CancelRequest, + /// Composer non-empty during a running turn — capture the input as a + /// pending steer, abort the turn, and re-submit on TurnComplete (#122). + SteerAndAbort, + DiscardQueuedDraft, + ClearInput, + Noop, +} + +fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { + if slash_menu_open { + EscapeAction::CloseSlashMenu + } else if app.is_loading { + if app.input.trim().is_empty() { + EscapeAction::CancelRequest + } else { + EscapeAction::SteerAndAbort + } + } else if app.queued_draft.is_some() && app.input.is_empty() { + EscapeAction::DiscardQueuedDraft + } else if !app.input.is_empty() { + EscapeAction::ClearInput + } else { + EscapeAction::Noop + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ApiKeyValidation { + Accept { warning: Option }, + Reject(String), +} + +fn validate_api_key_for_onboarding(api_key: &str) -> ApiKeyValidation { + let trimmed = api_key.trim(); + if trimmed.is_empty() { + return ApiKeyValidation::Reject("API key cannot be empty.".to_string()); + } + if trimmed.contains(char::is_whitespace) { + return ApiKeyValidation::Reject( + "API key appears malformed (contains whitespace).".to_string(), + ); + } + if trimmed.len() < 16 { + return ApiKeyValidation::Accept { + warning: Some( + "API key looks short. Double-check it, but unusual formats are allowed." + .to_string(), + ), + }; + } + if !trimmed.contains('-') { + return ApiKeyValidation::Accept { + warning: Some( + "API key format looks unusual. Check that the full key was copied.".to_string(), + ), + }; + } + ApiKeyValidation::Accept { warning: None } +} + +fn sync_api_key_validation_status(app: &mut App, show_empty_error: bool) { + if app.api_key_input.trim().is_empty() && !show_empty_error { + app.status_message = None; + return; + } + + match validate_api_key_for_onboarding(&app.api_key_input) { + ApiKeyValidation::Accept { warning } => { + app.status_message = warning; + } + ApiKeyValidation::Reject(message) => { + app.status_message = Some(message); + } + } +} + +fn build_queued_message(app: &mut App, input: String) -> QueuedMessage { + let skill_instruction = app.active_skill.take(); + QueuedMessage::new(input, skill_instruction) +} + +fn queued_message_content_for_app( + app: &App, + message: &QueuedMessage, + cwd: Option, +) -> String { + // Pass the process CWD explicitly so the resolver's two-pass logic can + // honor the user's launch directory when it differs from `--workspace` + // (issue #101 — file mentions silently routing to the wrong root). + let user_request = crate::tui::file_mention::user_request_with_file_mentions( + &message.display, + &app.workspace, + cwd, + ); + if let Some(skill_instruction) = message.skill_instruction.as_ref() { + format!("{skill_instruction}\n\n---\n\nUser request: {user_request}") + } else { + user_request + } +} + +async fn dispatch_user_message( + app: &mut App, + engine_handle: &EngineHandle, + message: QueuedMessage, +) -> Result<()> { + // Set immediately to prevent double-dispatch before TurnStarted event arrives. + app.is_loading = true; + app.last_send_at = Some(Instant::now()); + + let cwd = std::env::current_dir().ok(); + let references = crate::tui::file_mention::context_references_from_input( + &message.display, + &app.workspace, + cwd.clone(), + ); + let content = queued_message_content_for_app(app, &message, cwd); + let message_index = app.api_messages.len(); + app.system_prompt = Some(prompts::system_prompt_for_mode_with_context( + app.mode, + &app.workspace, + None, + )); + app.add_message(HistoryCell::User { + content: message.display.clone(), + }); + let history_cell = app.history.len().saturating_sub(1); + app.record_context_references(history_cell, message_index, references); + app.scroll_to_bottom(); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: content.clone(), + cache_control: None, + }], + }); + maybe_warn_context_pressure(app); + if should_auto_compact_before_send(app) { + app.status_message = Some("Context critical; compacting before send...".to_string()); + let _ = engine_handle.send(Op::CompactContext).await; + } + app.last_prompt_tokens = None; + app.last_completion_tokens = None; + app.last_prompt_cache_hit_tokens = None; + app.last_prompt_cache_miss_tokens = None; + app.last_reasoning_replay_tokens = None; + // Persist immediately so abrupt termination can recover this in-flight turn. + persist_checkpoint(app); + + engine_handle + .send(Op::SendMessage { + content, + mode: app.mode, + model: app.model.clone(), + reasoning_effort: app.reasoning_effort.api_value().map(str::to_string), + allow_shell: app.allow_shell, + trust_mode: app.trust_mode, + auto_approve: app.mode == AppMode::Yolo, + }) + .await?; + + Ok(()) +} + +async fn apply_model_and_compaction_update( + engine_handle: &EngineHandle, + compaction: crate::compaction::CompactionConfig, +) { + let _ = engine_handle + .send(Op::SetModel { + model: compaction.model.clone(), + }) + .await; + let _ = engine_handle + .send(Op::SetCompaction { config: compaction }) + .await; +} + +/// Apply the choice made in the `/model` picker (#39): mutate App state so +/// the next turn uses the new model/effort, persist the selection to +/// `~/.deepseek/settings.toml` so it survives a restart, push the change to +/// the running engine via `Op::SetModel`/`Op::SetCompaction`, and surface +/// a one-line status describing what changed. +async fn apply_model_picker_choice( + app: &mut App, + engine_handle: &EngineHandle, + model: String, + effort: crate::tui::app::ReasoningEffort, + previous_model: String, + previous_effort: crate::tui::app::ReasoningEffort, +) { + let model_changed = model != previous_model; + let effort_changed = effort != previous_effort; + if !model_changed && !effort_changed { + app.status_message = Some(format!( + "Model unchanged: {model} · thinking {}", + effort.short_label() + )); + return; + } + + if model_changed { + app.model = model.clone(); + app.update_model_compaction_budget(); + app.last_prompt_tokens = None; + app.last_completion_tokens = None; + app.last_prompt_cache_hit_tokens = None; + app.last_prompt_cache_miss_tokens = None; + app.last_reasoning_replay_tokens = None; + } + if effort_changed { + app.reasoning_effort = effort; + } + + // Best-effort persist; surface a status warning if the settings file + // can't be written rather than aborting the in-memory change. + let mut persist_warning: Option = None; + match crate::settings::Settings::load() { + Ok(mut settings) => { + if model_changed { + let _ = settings.set("default_model", &model); + } + if effort_changed { + let _ = settings.set("reasoning_effort", effort.as_setting()); + } + if let Err(err) = settings.save() { + persist_warning = Some(format!("(not persisted: {err})")); + } + } + Err(err) => { + persist_warning = Some(format!("(not persisted: {err})")); + } + } + + if model_changed { + apply_model_and_compaction_update(engine_handle, app.compaction_config()).await; + } + + let mut summary = match (model_changed, effort_changed) { + (true, true) => format!( + "Model: {previous_model} → {model} · thinking: {} → {}", + previous_effort.short_label(), + effort.short_label() + ), + (true, false) => format!( + "Model: {previous_model} → {model} · thinking {}", + effort.short_label() + ), + (false, true) => format!( + "Thinking: {} → {} · model {model}", + previous_effort.short_label(), + effort.short_label() + ), + (false, false) => unreachable!(), + }; + if let Some(warning) = persist_warning { + summary.push(' '); + summary.push_str(&warning); + } + app.status_message = Some(summary); +} + +/// Apply a `/provider` switch by mutating the in-memory config, validating +/// that credentials exist for the new provider, then respawning the engine +/// so the API client picks up the new base URL/key. When `model_override` +/// is set, it replaces the active model post-switch (already normalized, +/// will be provider-prefixed by `Config::default_model`). +async fn switch_provider( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &mut Config, + target: ApiProvider, + model_override: Option, +) { + let previous_provider = app.api_provider; + let previous_model = app.model.clone(); + let previous_provider_str = config.provider.clone(); + let previous_base_url = config.base_url.clone(); + let previous_default_text_model = config.default_text_model.clone(); + + config.provider = Some(target.as_str().to_string()); + if matches!(target, ApiProvider::NvidiaNim) + && config + .base_url + .as_deref() + .map(|base| !base.contains("integrate.api.nvidia.com")) + .unwrap_or(true) + { + config.base_url = Some(DEFAULT_NVIDIA_NIM_BASE_URL.to_string()); + } + if matches!(target, ApiProvider::Deepseek) + && config + .base_url + .as_deref() + .map(|base| base.contains("integrate.api.nvidia.com")) + .unwrap_or(false) + { + config.base_url = None; + } + if let Some(ref model) = model_override { + config.default_text_model = Some(model.clone()); + } + + if let Err(err) = DeepSeekClient::new(config) { + config.provider = previous_provider_str; + config.base_url = previous_base_url; + config.default_text_model = previous_default_text_model; + app.add_message(HistoryCell::System { + content: format!( + "Failed to switch provider to {}: {err}\nProvider unchanged ({}).", + target.as_str(), + previous_provider.as_str() + ), + }); + return; + } + + let new_model = config.default_model(); + app.api_provider = target; + app.model = new_model.clone(); + app.update_model_compaction_budget(); + app.last_prompt_tokens = None; + app.last_completion_tokens = None; + + let _ = engine_handle.send(Op::Shutdown).await; + let engine_config = build_engine_config(app, config); + *engine_handle = spawn_engine(engine_config, config); + + if !app.api_messages.is_empty() { + let _ = engine_handle + .send(Op::SyncSession { + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + }) + .await; + } + let _ = engine_handle + .send(Op::SetCompaction { + config: app.compaction_config(), + }) + .await; + + app.add_message(HistoryCell::System { + content: format!( + "Provider switched: {} → {}\nModel: {} → {}", + previous_provider.as_str(), + target.as_str(), + previous_model, + new_model + ), + }); + app.status_message = Some(format!("Provider: {}", target.as_str())); +} + +fn open_text_pager(app: &mut App, title: String, content: String) { + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + app.view_stack.push(PagerView::from_text( + title, + &content, + width.saturating_sub(2), + )); +} + +fn open_context_inspector(app: &mut App) { + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let content = build_context_inspector_text(app); + app.view_stack.push(PagerView::from_text( + "Context inspector", + &content, + width.saturating_sub(2), + )); +} + +fn open_file_picker(app: &mut App) { + let relevance = build_file_picker_relevance(app); + app.view_stack + .push(crate::tui::file_picker::FilePickerView::new_with_relevance( + &app.workspace, + relevance, + )); +} + +fn build_file_picker_relevance(app: &App) -> crate::tui::file_picker::FilePickerRelevance { + let mut relevance = crate::tui::file_picker::FilePickerRelevance::default(); + + for path in modified_workspace_paths(&app.workspace) { + relevance.mark_modified(path); + } + + for record in app.session_context_references.iter().rev().take(64) { + let reference = &record.reference; + if reference.source != crate::tui::file_mention::ContextReferenceSource::AtMention { + continue; + } + if !matches!( + reference.kind, + crate::tui::file_mention::ContextReferenceKind::File + ) { + continue; + } + for raw in [&reference.target, &reference.label] { + if let Some(path) = workspace_file_candidate(raw, &app.workspace) { + relevance.mark_mentioned(path); + } + } + } + + let mut seen_tool_paths = HashSet::new(); + for detail in app.active_tool_details.values() { + mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance); + } + let mut rows: Vec<_> = app.tool_details_by_cell.iter().collect(); + rows.sort_by_key(|(idx, _)| std::cmp::Reverse(**idx)); + for (_, detail) in rows.into_iter().take(48) { + mark_tool_detail_paths(detail, &app.workspace, &mut seen_tool_paths, &mut relevance); + } + + relevance +} + +fn modified_workspace_paths(workspace: &Path) -> Vec { + let Ok(output) = Command::new("git") + .arg("-C") + .arg(workspace) + .args(["status", "--short", "--untracked-files=normal"]) + .output() + else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_git_status_path) + .filter_map(|path| workspace_file_candidate(&path, workspace)) + .collect() +} + +fn parse_git_status_path(line: &str) -> Option { + if line.len() < 4 { + return None; + } + let raw = line.get(3..)?.trim(); + let raw = raw.rsplit(" -> ").next().unwrap_or(raw).trim(); + let raw = raw.trim_matches('"'); + if raw.is_empty() { + None + } else { + Some(raw.to_string()) + } +} + +fn mark_tool_detail_paths( + detail: &ToolDetailRecord, + workspace: &Path, + seen: &mut HashSet, + relevance: &mut crate::tui::file_picker::FilePickerRelevance, +) { + let mut budget = 256usize; + mark_tool_paths_from_value(&detail.input, workspace, seen, relevance, &mut budget); + if let Some(output) = detail + .output + .as_deref() + .filter(|output| output.len() <= 8_192) + { + mark_tool_paths_from_text(output, workspace, seen, relevance, &mut budget); + } +} + +fn mark_tool_paths_from_value( + value: &serde_json::Value, + workspace: &Path, + seen: &mut HashSet, + relevance: &mut crate::tui::file_picker::FilePickerRelevance, + budget: &mut usize, +) { + if *budget == 0 { + return; + } + match value { + serde_json::Value::String(text) => { + mark_tool_paths_from_text(text, workspace, seen, relevance, budget); + } + serde_json::Value::Array(items) => { + for item in items { + mark_tool_paths_from_value(item, workspace, seen, relevance, budget); + if *budget == 0 { + break; + } + } + } + serde_json::Value::Object(map) => { + for item in map.values() { + mark_tool_paths_from_value(item, workspace, seen, relevance, budget); + if *budget == 0 { + break; + } + } + } + _ => {} + } +} + +fn mark_tool_paths_from_text( + text: &str, + workspace: &Path, + seen: &mut HashSet, + relevance: &mut crate::tui::file_picker::FilePickerRelevance, + budget: &mut usize, +) { + if *budget == 0 || text.len() > 8_192 { + return; + } + if let Some(path) = workspace_file_candidate(text, workspace) + && seen.insert(path.clone()) + { + relevance.mark_tool(path); + *budget = (*budget).saturating_sub(1); + } + for token in text.split_whitespace().take(128) { + if *budget == 0 { + break; + } + if let Some(path) = workspace_file_candidate(token, workspace) + && seen.insert(path.clone()) + { + relevance.mark_tool(path); + *budget = (*budget).saturating_sub(1); + } + } +} + +fn workspace_file_candidate(raw: &str, workspace: &Path) -> Option { + let cleaned = clean_path_token(raw)?; + let path = Path::new(&cleaned); + let absolute = if path.is_absolute() { + PathBuf::from(path) + } else { + workspace.join(path) + }; + if !absolute.is_file() { + return None; + } + let rel = absolute.strip_prefix(workspace).ok()?; + workspace_path_to_picker_string(rel) +} + +fn clean_path_token(raw: &str) -> Option { + let mut trimmed = raw.trim().trim_matches(|ch: char| { + ch.is_ascii_whitespace() + || matches!( + ch, + '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' + ) + }); + if let Some(stripped) = trimmed.strip_prefix("./") { + trimmed = stripped; + } + if let Some((before, after)) = trimmed.rsplit_once(':') + && !before.is_empty() + && after.chars().all(|ch| ch.is_ascii_digit()) + { + trimmed = before; + } + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn workspace_path_to_picker_string(path: &Path) -> Option { + let mut out = String::new(); + for (idx, component) in path.components().enumerate() { + if matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) { + return None; + } + if idx > 0 { + out.push('/'); + } + out.push_str(&component.as_os_str().to_string_lossy()); + } + if out.is_empty() { None } else { Some(out) } +} + +async fn apply_command_result( + app: &mut App, + engine_handle: &mut EngineHandle, + task_manager: &SharedTaskManager, + config: &mut Config, + result: commands::CommandResult, +) -> Result { + if let Some(msg) = result.message { + app.add_message(HistoryCell::System { content: msg }); + } + + if let Some(action) = result.action { + match action { + AppAction::Quit => { + let _ = engine_handle.send(Op::Shutdown).await; + return Ok(true); + } + AppAction::SaveSession(path) => { + app.status_message = Some(format!("Session saved to {}", path.display())); + } + AppAction::LoadSession(path) => { + app.status_message = Some(format!("Session loaded from {}", path.display())); + } + AppAction::SyncSession { + messages, + system_prompt, + model, + workspace, + } => { + let is_full_reset = messages.is_empty() && system_prompt.is_none(); + let _ = engine_handle + .send(Op::SyncSession { + messages, + system_prompt, + model, + workspace, + }) + .await; + let _ = engine_handle + .send(Op::SetCompaction { + config: app.compaction_config(), + }) + .await; + if is_full_reset { + persist_session_snapshot(app); + clear_checkpoint(); + } + } + AppAction::SendMessage(content) => { + let queued = build_queued_message(app, content); + submit_or_steer_message(app, engine_handle, queued).await?; + } + AppAction::Rlm { + prompt, + model, + child_model, + max_depth, + } => { + app.status_message = Some("RLM turn starting...".to_string()); + let _ = engine_handle + .send(Op::Rlm { + content: prompt, + model, + child_model, + max_depth, + }) + .await; + } + AppAction::ListSubAgents => { + let _ = engine_handle.send(Op::ListSubAgents).await; + } + AppAction::FetchModels => { + app.status_message = Some("Fetching models...".to_string()); + match fetch_available_models(config).await { + Ok(models) => { + app.add_message(HistoryCell::System { + content: format_available_models_message(&app.model, &models), + }); + app.status_message = Some(format!("Found {} model(s)", models.len())); + } + Err(error) => { + app.add_message(HistoryCell::System { + content: format!("Failed to fetch models: {error}"), + }); + } + } + } + AppAction::SwitchProvider { provider, model } => { + switch_provider(app, engine_handle, config, provider, model).await; + } + AppAction::UpdateCompaction(compaction) => { + apply_model_and_compaction_update(engine_handle, compaction).await; + } + AppAction::OpenConfigView => { + if app.view_stack.top_kind() != Some(ModalKind::Config) { + app.view_stack.push(ConfigView::new_for_app(app)); + } + } + AppAction::OpenModelPicker => { + if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) { + app.view_stack + .push(crate::tui::model_picker::ModelPickerView::new(app)); + } + } + AppAction::OpenProviderPicker => { + if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) { + app.view_stack + .push(crate::tui::provider_picker::ProviderPickerView::new( + app.api_provider, + config, + )); + } + } + AppAction::OpenStatusPicker => { + if app.view_stack.top_kind() != Some(ModalKind::StatusPicker) { + app.view_stack + .push(crate::tui::views::status_picker::StatusPickerView::new( + &app.status_items, + )); + } + } + AppAction::OpenContextInspector => { + open_context_inspector(app); + } + AppAction::CompactContext => { + app.status_message = Some("Compacting context...".to_string()); + let _ = engine_handle.send(Op::CompactContext).await; + } + AppAction::TaskAdd { prompt } => { + let request = NewTaskRequest { + prompt: prompt.clone(), + model: Some(app.model.clone()), + workspace: Some(app.workspace.clone()), + mode: Some(task_mode_label(app.mode).to_string()), + allow_shell: Some(app.allow_shell), + trust_mode: Some(app.trust_mode), + auto_approve: Some(app.approval_mode == ApprovalMode::Auto), + }; + match task_manager.add_task(request).await { + Ok(task) => { + app.add_message(HistoryCell::System { + content: format!( + "Task queued: {} ({})", + task.id, + summarize_tool_output(&task.prompt) + ), + }); + app.status_message = Some(format!("Queued {}", task.id)); + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!("Failed to queue task: {err}"), + }); + } + } + app.task_panel = task_manager + .list_tasks(Some(10)) + .await + .into_iter() + .map(task_summary_to_panel_entry) + .collect(); + } + AppAction::TaskList => { + let tasks = task_manager.list_tasks(Some(30)).await; + app.task_panel = tasks + .iter() + .cloned() + .map(task_summary_to_panel_entry) + .collect(); + app.add_message(HistoryCell::System { + content: format_task_list(&tasks), + }); + } + AppAction::TaskShow { id } => match task_manager.get_task(&id).await { + Ok(task) => open_task_pager(app, &task), + Err(err) => { + app.add_message(HistoryCell::System { + content: format!("Task lookup failed: {err}"), + }); + } + }, + AppAction::TaskCancel { id } => { + match task_manager.cancel_task(&id).await { + Ok(task) => { + app.add_message(HistoryCell::System { + content: format!("Task {} status: {:?}", task.id, task.status), + }); + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!("Task cancel failed: {err}"), + }); + } + } + app.task_panel = task_manager + .list_tasks(Some(10)) + .await + .into_iter() + .map(task_summary_to_panel_entry) + .collect(); + } + } + } + + Ok(false) +} + +async fn execute_command_input( + app: &mut App, + engine_handle: &mut EngineHandle, + task_manager: &SharedTaskManager, + config: &mut Config, + input: &str, +) -> Result { + let result = commands::execute(input, app); + apply_command_result(app, engine_handle, task_manager, config, result).await +} + +async fn steer_user_message( + app: &mut App, + engine_handle: &EngineHandle, + message: QueuedMessage, +) -> Result<()> { + let cwd = std::env::current_dir().ok(); + let references = crate::tui::file_mention::context_references_from_input( + &message.display, + &app.workspace, + cwd.clone(), + ); + let content = queued_message_content_for_app(app, &message, cwd); + let message_index = app.api_messages.len(); + + // Mirror steer input in local transcript/session state. + app.add_message(HistoryCell::User { + content: format!("+ {}", message.display), + }); + let history_cell = app.history.len().saturating_sub(1); + app.record_context_references(history_cell, message_index, references); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: content.clone(), + cache_control: None, + }], + }); + + engine_handle.steer(content).await?; + app.status_message = Some("Steering current turn...".to_string()); + Ok(()) +} + +async fn submit_or_steer_message( + app: &mut App, + engine_handle: &EngineHandle, + message: QueuedMessage, +) -> Result<()> { + match app.decide_submit_disposition() { + SubmitDisposition::Immediate => dispatch_user_message(app, engine_handle, message).await, + SubmitDisposition::Queue => { + app.queue_message(message); + app.status_message = Some(format!( + "Offline mode: queued {} message(s) - /queue to review", + app.queued_message_count() + )); + Ok(()) + } + SubmitDisposition::Steer => { + if let Err(err) = steer_user_message(app, engine_handle, message.clone()).await { + app.queue_message(message); + app.status_message = Some(format!( + "Steer failed ({err}); queued {} message(s) - /queue to view/edit", + app.queued_message_count() + )); + } + Ok(()) + } + } +} + +/// Drain `app.pending_steers` into a single `QueuedMessage` ready for +/// `dispatch_user_message`. Returns `None` if the queue was empty (caller +/// then falls back to `app.queued_messages`). Skill instruction is taken +/// from the first message that supplies one — multiple steers shouldn't +/// double-up the system framing. +fn merge_pending_steers(app: &mut App) -> Option { + let drained = app.drain_pending_steers(); + if drained.is_empty() { + return None; + } + if drained.len() == 1 { + return drained.into_iter().next(); + } + let mut skill_instruction: Option = None; + let mut bodies: Vec = Vec::with_capacity(drained.len()); + for msg in drained { + if skill_instruction.is_none() { + skill_instruction = msg.skill_instruction; + } + bodies.push(msg.display); + } + Some(QueuedMessage::new(bodies.join("\n\n"), skill_instruction)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PlanChoice { + AcceptAgent, + AcceptYolo, + RevisePlan, + ExitPlan, +} + +fn plan_next_step_prompt() -> String { + [ + "Action required: choose the next step for this plan.", + " 1) Accept + implement in Agent mode", + " 2) Accept + implement in YOLO mode", + " 3) Revise the plan / ask follow-ups", + " 4) Return to Agent mode without implementing", + "", + "Use the plan confirmation popup, or type 1-4 and press Enter.", + ] + .join("\n") +} + +fn plan_choice_from_option(option: usize) -> Option { + match option { + 1 => Some(PlanChoice::AcceptAgent), + 2 => Some(PlanChoice::AcceptYolo), + 3 => Some(PlanChoice::RevisePlan), + 4 => Some(PlanChoice::ExitPlan), + _ => None, + } +} + +fn parse_plan_choice(input: &str) -> Option { + // Once the modal is dismissed, only the advertised 1-4 fallback remains active. + // Letter shortcuts stay modal-only so normal messages like "yolo" are not captured. + match input.trim() { + "1" => Some(PlanChoice::AcceptAgent), + "2" => Some(PlanChoice::AcceptYolo), + "3" => Some(PlanChoice::RevisePlan), + "4" => Some(PlanChoice::ExitPlan), + _ => None, + } +} + +async fn apply_plan_choice( + app: &mut App, + engine_handle: &EngineHandle, + choice: PlanChoice, +) -> Result<()> { + match choice { + PlanChoice::AcceptAgent => { + app.set_mode(AppMode::Agent); + app.add_message(HistoryCell::System { + content: "Plan accepted. Switching to Agent mode and starting implementation." + .to_string(), + }); + let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None); + if app.is_loading { + app.queue_message(followup); + app.status_message = + Some("Queued accepted plan execution (agent mode).".to_string()); + } else { + dispatch_user_message(app, engine_handle, followup).await?; + } + } + PlanChoice::AcceptYolo => { + app.set_mode(AppMode::Yolo); + app.add_message(HistoryCell::System { + content: "Plan accepted. Switching to YOLO mode and starting implementation." + .to_string(), + }); + let followup = QueuedMessage::new("Proceed with the accepted plan.".to_string(), None); + if app.is_loading { + app.queue_message(followup); + app.status_message = + Some("Queued accepted plan execution (YOLO mode).".to_string()); + } else { + dispatch_user_message(app, engine_handle, followup).await?; + } + } + PlanChoice::RevisePlan => { + let prompt = "Revise the plan: "; + app.input = prompt.to_string(); + app.cursor_position = prompt.chars().count(); + app.status_message = Some("Revise the plan and press Enter.".to_string()); + } + PlanChoice::ExitPlan => { + app.set_mode(AppMode::Agent); + app.add_message(HistoryCell::System { + content: "Exited Plan mode. Switched to Agent mode.".to_string(), + }); + } + } + + Ok(()) +} + +async fn handle_plan_choice( + app: &mut App, + engine_handle: &EngineHandle, + input: &str, +) -> Result { + if !app.plan_prompt_pending { + return Ok(false); + } + + let choice = parse_plan_choice(input); + app.plan_prompt_pending = false; + + let Some(choice) = choice else { + return Ok(false); + }; + + apply_plan_choice(app, engine_handle, choice).await?; + Ok(true) +} + +fn running_agent_count(app: &App) -> usize { + let mut ids: std::collections::HashSet<&str> = + app.agent_progress.keys().map(String::as_str).collect(); + for agent in app + .subagent_cache + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + { + ids.insert(agent.agent_id.as_str()); + } + ids.len() +} + +fn reconcile_subagent_activity_state(app: &mut App) { + let running_agents: Vec<(String, String)> = app + .subagent_cache + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + .map(|agent| { + ( + agent.agent_id.clone(), + summarize_tool_output(&agent.assignment.objective), + ) + }) + .collect(); + + let running_ids: std::collections::HashSet = + running_agents.iter().map(|(id, _)| id.clone()).collect(); + app.agent_progress + .retain(|id, _| running_ids.contains(id.as_str())); + for (id, objective) in running_agents { + app.agent_progress.entry(id).or_insert(objective); + } + + if running_ids.is_empty() { + app.agent_activity_started_at = None; + } else if app.agent_activity_started_at.is_none() { + app.agent_activity_started_at = Some(Instant::now()); + } +} + +/// Build the pending-input preview widget from current `App` state. +/// +/// v0.6.6 (#122) wires all three buckets: +/// - `pending_steers` — typed during a running turn + Esc; held until the +/// abort lands and gets resubmitted as a fresh merged turn. +/// - `rejected_steers` — engine declined a mid-turn steer (scaffolding; +/// no engine path produces these yet but the bucket renders identically). +/// - `queued_messages` — Enter while busy (offline-mode FIFO); drained at +/// end-of-turn. +fn build_pending_input_preview(app: &App) -> PendingInputPreview { + let mut preview = PendingInputPreview::new(); + preview.context_items = crate::tui::file_mention::pending_context_previews( + &app.input, + &app.workspace, + std::env::current_dir().ok(), + ) + .into_iter() + .map(|item| ContextPreviewItem { + kind: item.kind, + label: item.label, + detail: item.detail, + included: item.included, + }) + .collect(); + preview.pending_steers = app + .pending_steers + .iter() + .map(|m| m.display.clone()) + .collect(); + preview.rejected_steers = app.rejected_steers.iter().cloned().collect(); + preview.queued_messages = app + .queued_messages + .iter() + .map(|m| m.display.clone()) + .collect(); + preview +} + +fn render(f: &mut Frame, app: &mut App) { + let size = f.area(); + + // Clear entire area with background color + let background = Block::default().style(Style::default().bg(app.ui_theme.header_bg)); + f.render_widget(background, size); + + // Show onboarding screen if needed + if app.onboarding != OnboardingState::None { + onboarding::render(f, size, app); + return; + } + + let header_height = 1; + let footer_height = 1; + let body_height = size.height.saturating_sub(header_height + footer_height); + let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT); + let mention_menu_entries = + crate::tui::file_mention::visible_mention_menu_entries(app, MENTION_MENU_LIMIT); + if !mention_menu_entries.is_empty() && app.mention_menu_selected >= mention_menu_entries.len() { + app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1); + } + let context_usage = context_usage_snapshot(app); + let composer_max_height = body_height + .saturating_sub(MIN_CHAT_HEIGHT) + .max(MIN_COMPOSER_HEIGHT); + let composer_height = { + let composer_widget = ComposerWidget::new( + app, + composer_max_height, + &slash_menu_entries, + &mention_menu_entries, + ); + composer_widget.desired_height(size.width) + }; + + // Pending-input preview (queued / steered messages). Empty when nothing's + // queued, so zero height when idle. Phase 2 of #85 — solves the + // "messages typed during a running turn vanish" complaint by giving the + // user immediate visible feedback above the composer. + let pending_preview = build_pending_input_preview(app); + let preview_height = pending_preview.desired_height(size.width); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(header_height), // Header + Constraint::Min(1), // Chat area + Constraint::Length(preview_height), // Pending input preview (0 if empty) + Constraint::Length(composer_height), // Composer + Constraint::Length(footer_height), // Footer + ]) + .split(size); + + // Render header + { + let sanitized_context_window = context_usage + .as_ref() + .map(|(_, max, _)| *max) + .or_else(|| crate::models::context_window_for_model(&app.model)); + let sanitized_prompt_tokens = context_usage + .as_ref() + .and_then(|(used, _, _)| u32::try_from(*used).ok()); + let workspace_name = app + .workspace + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("workspace"); + let effort_label = app.reasoning_effort.short_label(); + let provider_label = match app.api_provider { + crate::config::ApiProvider::Deepseek => None, + crate::config::ApiProvider::NvidiaNim => Some("NIM"), + crate::config::ApiProvider::Openrouter => Some("OR"), + crate::config::ApiProvider::Novita => Some("Novita"), + }; + let header_data = HeaderData::new( + app.mode, + &app.model, + workspace_name, + app.is_loading, + app.ui_theme.header_bg, + ) + .with_usage( + app.total_conversation_tokens, + sanitized_context_window, + app.session_cost, + sanitized_prompt_tokens, + ) + .with_reasoning_effort(Some(effort_label)) + .with_provider(provider_label); + let header_widget = HeaderWidget::new(header_data); + let buf = f.buffer_mut(); + header_widget.render(chunks[0], buf); + } + + // Render chat + sidebar + { + let mut chat_area = chunks[1]; + let mut sidebar_area = None; + + if chunks[1].width >= SIDEBAR_VISIBLE_MIN_WIDTH { + let preferred_sidebar = (u32::from(chunks[1].width) + * u32::from(app.sidebar_width_percent.clamp(10, 50)) + / 100) as u16; + let sidebar_width = preferred_sidebar + .max(24) + .min(chunks[1].width.saturating_sub(40)); + if sidebar_width >= 20 { + let split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(1), Constraint::Length(sidebar_width)]) + .split(chunks[1]); + chat_area = split[0]; + sidebar_area = Some(split[1]); + } + } + + let chat_widget = ChatWidget::new(app, chat_area); + let buf = f.buffer_mut(); + chat_widget.render(chat_area, buf); + + if let Some(sidebar_area) = sidebar_area { + super::sidebar::render_sidebar(f, sidebar_area, app); + } + } + + // Render pending-input preview (queued/steered messages, if any). + if preview_height > 0 { + let buf = f.buffer_mut(); + pending_preview.render(chunks[2], buf); + } + + // Render composer + let cursor_pos = { + let composer_widget = ComposerWidget::new( + app, + composer_max_height, + &slash_menu_entries, + &mention_menu_entries, + ); + let buf = f.buffer_mut(); + composer_widget.render(chunks[3], buf); + composer_widget.cursor_pos(chunks[3]) + }; + if let Some(cursor_pos) = cursor_pos { + f.set_cursor_position(cursor_pos); + } + + // Render footer + render_footer(f, chunks[4], app); + + if !app.view_stack.is_empty() { + // The live transcript overlay snapshots the app's history + active + // cell on each render so streaming mutations propagate. Other views + // are static and skip this refresh. + if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) { + refresh_live_transcript_overlay(app); + } + let buf = f.buffer_mut(); + app.view_stack.render(size, buf); + } +} + +/// Pull the latest snapshot of cells / revisions / render options into the +/// live transcript overlay sitting on top of the view stack. No-op if the +/// top view isn't a `LiveTranscriptOverlay`. +fn refresh_live_transcript_overlay(app: &mut App) { + // Pop+push lets us hold &mut to the overlay while also borrowing `app` + // mutably for the snapshot — direct re-borrow through `view_stack` + // would otherwise alias `app`. + let Some(mut overlay) = app.view_stack.pop() else { + return; + }; + if let Some(typed) = overlay.as_any_mut().downcast_mut::() { + typed.refresh_from_app(app); + } + app.view_stack.push_boxed(overlay); +} + +/// Open the live transcript overlay in backtrack-preview mode (#133). +/// The overlay starts highlighting the most recent user message +/// (`selected_idx = 0`) and routes Left/Right/Enter/Esc through +/// `ViewEvent::Backtrack*` so the main key dispatcher can advance the +/// `BacktrackState` and apply the rewind on confirm. +fn open_backtrack_overlay(app: &mut App) { + let mut overlay = LiveTranscriptOverlay::new(); + overlay.refresh_from_app(app); + overlay.set_backtrack_preview(0); + app.view_stack.push(overlay); + app.status_message = + Some("Backtrack: \u{2190}/\u{2192} step Enter rewind Esc cancel".to_string()); + app.needs_redraw = true; +} + +/// Toggle the live transcript overlay on `Ctrl+T`. Closes the overlay if it's +/// already on top; otherwise pushes a fresh one in sticky-tail mode. +fn toggle_live_transcript_overlay(app: &mut App) { + if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) { + app.view_stack.pop(); + app.needs_redraw = true; + return; + } + let mut overlay = LiveTranscriptOverlay::new(); + overlay.refresh_from_app(app); + app.view_stack.push(overlay); + app.status_message = Some("Live transcript: tailing (Esc to close)".to_string()); + app.needs_redraw = true; +} + +async fn handle_view_events( + app: &mut App, + config: &mut Config, + task_manager: &SharedTaskManager, + engine_handle: &mut EngineHandle, + events: Vec, +) -> Result { + for event in events { + match event { + ViewEvent::CommandPaletteSelected { action } => match action { + crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => { + if execute_command_input(app, engine_handle, task_manager, config, &command) + .await? + { + return Ok(true); + } + } + crate::tui::views::CommandPaletteAction::InsertText { text } => { + app.input = text; + app.cursor_position = app.input.chars().count(); + app.status_message = Some( + "Inserted into composer. Finish the input or press Enter.".to_string(), + ); + } + crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => { + open_text_pager(app, title, content); + } + }, + ViewEvent::OpenTextPager { title, content } => { + open_text_pager(app, title, content); + } + ViewEvent::ApprovalDecision { + tool_id, + tool_name, + decision, + timed_out, + approval_key, + } => { + if decision == ReviewDecision::ApprovedForSession { + // Store both the tool name (backward compat) and the + // approval key (fingerprint-based). + app.approval_session_approved.insert(tool_name.clone()); + app.approval_session_approved.insert(approval_key); + } + + match decision { + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { + let _ = engine_handle.approve_tool_call(tool_id).await; + } + ReviewDecision::Denied | ReviewDecision::Abort => { + let _ = engine_handle.deny_tool_call(tool_id).await; + } + } + + if timed_out { + app.add_message(HistoryCell::System { + content: "Approval request timed out - denied".to_string(), + }); + } + } + ViewEvent::ElevationDecision { + tool_id, + tool_name, + option, + } => { + use crate::tui::approval::ElevationOption; + match option { + ElevationOption::Abort => { + let _ = engine_handle.deny_tool_call(tool_id).await; + app.add_message(HistoryCell::System { + content: format!("Sandbox elevation aborted for {tool_name}"), + }); + } + ElevationOption::WithNetwork => { + app.add_message(HistoryCell::System { + content: format!("Retrying {tool_name} with network access enabled"), + }); + let policy = option.to_policy(&app.workspace); + let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; + } + ElevationOption::WithWriteAccess(_) => { + app.add_message(HistoryCell::System { + content: format!("Retrying {tool_name} with write access enabled"), + }); + let policy = option.to_policy(&app.workspace); + let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; + } + ElevationOption::FullAccess => { + app.add_message(HistoryCell::System { + content: format!("Retrying {tool_name} with full access (no sandbox)"), + }); + let policy = option.to_policy(&app.workspace); + let _ = engine_handle.retry_tool_with_policy(tool_id, policy).await; + } + } + } + ViewEvent::UserInputSubmitted { tool_id, response } => { + let _ = engine_handle.submit_user_input(tool_id, response).await; + } + ViewEvent::UserInputCancelled { tool_id } => { + let _ = engine_handle.cancel_user_input(tool_id).await; + app.add_message(HistoryCell::System { + content: "User input cancelled".to_string(), + }); + } + ViewEvent::PlanPromptSelected { option } => { + if app.plan_prompt_pending { + app.plan_prompt_pending = false; + if let Some(choice) = plan_choice_from_option(option) + && let Err(err) = apply_plan_choice(app, engine_handle, choice).await + { + app.status_message = Some(format!("Failed to apply plan selection: {err}")); + } + } + } + ViewEvent::PlanPromptDismissed => { + app.plan_prompt_pending = true; + app.status_message = + Some("Plan prompt closed. Type 1-4 and press Enter to choose.".to_string()); + } + ViewEvent::SessionSelected { session_id } => { + let manager = match SessionManager::default_location() { + Ok(manager) => manager, + Err(err) => { + app.status_message = + Some(format!("Failed to open sessions directory: {err}")); + continue; + } + }; + + match manager.load_session(&session_id) { + Ok(session) => { + apply_loaded_session(app, &session); + let _ = engine_handle + .send(Op::SyncSession { + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + }) + .await; + let _ = engine_handle + .send(Op::SetCompaction { + config: app.compaction_config(), + }) + .await; + app.status_message = Some(format!( + "Session loaded (ID: {})", + &session_id[..8.min(session_id.len())] + )); + } + Err(err) => { + app.status_message = + Some(format!("Failed to load session {session_id}: {err}")); + } + } + } + ViewEvent::SessionDeleted { session_id, title } => { + app.status_message = Some(format!( + "Deleted session {} ({})", + &session_id[..8.min(session_id.len())], + title + )); + } + ViewEvent::ConfigUpdated { + key, + value, + persist, + } => { + let result = commands::set_config_value(app, &key, &value, persist); + if let Some(msg) = result.message { + app.add_message(HistoryCell::System { content: msg }); + } + + if let Some(action) = result.action { + match action { + AppAction::UpdateCompaction(compaction) => { + apply_model_and_compaction_update(engine_handle, compaction).await; + } + AppAction::OpenConfigView => {} + _ => {} + } + } + + if app.view_stack.top_kind() == Some(ModalKind::Config) { + app.view_stack.pop(); + app.view_stack.push(ConfigView::new_for_app(app)); + } + } + ViewEvent::StatusItemsUpdated { items, final_save } => { + // Apply to the live App immediately so the footer reflects + // every keystroke (live preview). + app.status_items = items.clone(); + app.needs_redraw = true; + if final_save { + match commands::persist_status_items(&items) { + Ok(path) => { + app.status_message = + Some(format!("Status line saved to {}", path.display())); + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!("Failed to save status line: {err}"), + }); + } + } + } + } + ViewEvent::SubAgentsRefresh => { + app.status_message = Some("Refreshing sub-agents...".to_string()); + let _ = engine_handle.send(Op::ListSubAgents).await; + } + ViewEvent::FilePickerSelected { path } => { + // Insert `@` at the composer's cursor with surrounding + // whitespace so the existing `@`-mention parser picks it up. + let cursor = app.cursor_position; + let needs_leading_space = cursor > 0 + && !app + .input + .chars() + .nth(cursor.saturating_sub(1)) + .is_some_and(|c| c.is_whitespace()); + let mut insertion = String::new(); + if needs_leading_space { + insertion.push(' '); + } + insertion.push('@'); + insertion.push_str(&path); + insertion.push(' '); + app.insert_str(&insertion); + app.status_message = Some(format!("Attached @{path}")); + } + ViewEvent::ModelPickerApplied { + model, + effort, + previous_model, + previous_effort, + } => { + apply_model_picker_choice( + app, + engine_handle, + model, + effort, + previous_model, + previous_effort, + ) + .await; + } + ViewEvent::ProviderPickerApplied { provider } => { + switch_provider(app, engine_handle, config, provider, None).await; + } + ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => { + apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await; + } + ViewEvent::BacktrackStep { direction } => { + app.backtrack.step(direction); + if let Some(idx) = app.backtrack.selected_idx() { + update_backtrack_overlay_selection(app, idx); + } + } + ViewEvent::BacktrackConfirm => { + if let Some(depth) = app.backtrack.confirm() { + apply_backtrack(app, depth); + } + } + ViewEvent::BacktrackCancel => { + app.backtrack.reset(); + app.status_message = Some("Backtrack canceled".to_string()); + app.needs_redraw = true; + } + } + } + + Ok(false) +} + +/// Push the new `selected_idx` into the live transcript overlay so the +/// highlight follows the user's Left/Right input. No-op if the overlay is +/// no longer on top (e.g. it was closed underneath us). +fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) { + if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) { + return; + } + let Some(mut overlay) = app.view_stack.pop() else { + return; + }; + if let Some(typed) = overlay.as_any_mut().downcast_mut::() { + typed.set_backtrack_preview(selected_idx); + } + app.view_stack.push_boxed(overlay); + app.needs_redraw = true; +} + +/// Count how many `HistoryCell::User` entries currently live in the +/// transcript. Used by the backtrack state machine to decide whether +/// there's anything to rewind to. Walks `app.history` directly so it +/// stays accurate even mid-stream (the streaming Assistant cell never +/// counts as a user turn). +fn count_user_history_cells(app: &App) -> usize { + app.history + .iter() + .filter(|cell| matches!(cell, HistoryCell::User { .. })) + .count() +} + +/// Find the absolute index of the Nth-from-tail `HistoryCell::User` in +/// `app.history`. `depth` of 0 selects the most recent user cell. +/// Returns `None` if `depth` is out of range. +fn find_user_cell_index_from_tail(app: &App, depth: usize) -> Option { + let mut count = 0usize; + for (idx, cell) in app.history.iter().enumerate().rev() { + if matches!(cell, HistoryCell::User { .. }) { + if count == depth { + return Some(idx); + } + count += 1; + } + } + None +} + +/// Apply the user's backtrack selection: trim `app.history` and +/// `app.api_messages` so everything from the chosen user message onward +/// is dropped, populate the composer with the dropped user text, close +/// the overlay, and surface a status hint. The cycle counter is bumped +/// so any persistent indices clear; the engine's in-flight context is +/// re-synced via `Op::SyncSession` so the next turn starts fresh. +fn apply_backtrack(app: &mut App, depth: usize) { + let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else { + app.status_message = Some("Backtrack target no longer present".to_string()); + return; + }; + + // Snapshot the user text before truncating so we can refill the + // composer. + let user_text = match app.history.get(history_idx) { + Some(HistoryCell::User { content }) => content.clone(), + _ => String::new(), + }; + + // Trim the visible transcript at the chosen user cell. Per-cell + // revisions and tool-cell maps are kept consistent through + // `App::truncate_history_to`. + app.truncate_history_to(history_idx); + + // Trim the API-message log at the matching user message. We + // re-walk `api_messages` from the tail, counting role=="user" + // boundaries so the depth aligns with what the model sees on the + // next turn. + let mut user_seen = 0usize; + let mut cut = None; + for (idx, msg) in app.api_messages.iter().enumerate().rev() { + if msg.role == "user" { + if user_seen == depth { + cut = Some(idx); + break; + } + user_seen += 1; + } + } + if let Some(idx) = cut { + app.api_messages.truncate(idx); + } + + // Hand the dropped text back to the user so they can edit + resend. + app.input = user_text; + app.cursor_position = app.input.chars().count(); + + // Close the overlay, refresh sticky-tail flag, and surface a hint. + if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) { + app.view_stack.pop(); + } + app.status_message = + Some("Rewound to previous user message — edit and Enter to resend".to_string()); + app.scroll_to_bottom(); + app.mark_history_updated(); + app.needs_redraw = true; +} + +/// Persist the typed API key to `~/.deepseek/config.toml`, refresh the +/// in-memory config so the engine can see it, then switch to the provider. +async fn apply_provider_picker_api_key( + app: &mut App, + engine_handle: &mut EngineHandle, + config: &mut Config, + provider: ApiProvider, + api_key: String, +) { + use crate::config::{ProviderConfig, ProvidersConfig, save_api_key_for}; + + match save_api_key_for(provider, &api_key) { + Ok(path) => { + app.status_message = Some(format!( + "Saved {} API key to {}", + provider.as_str(), + path.display() + )); + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!( + "Failed to save {} API key: {err}\nProvider unchanged.", + provider.as_str() + ), + }); + return; + } + } + + // Mirror the saved key into the in-memory config so the engine sees it + // immediately without a reload — `save_api_key_for` only touches disk. + if matches!(provider, ApiProvider::Deepseek) { + config.api_key = Some(api_key); + } else { + let providers = config + .providers + .get_or_insert_with(ProvidersConfig::default); + let entry: &mut ProviderConfig = match provider { + ApiProvider::Deepseek => unreachable!(), + ApiProvider::NvidiaNim => &mut providers.nvidia_nim, + ApiProvider::Openrouter => &mut providers.openrouter, + ApiProvider::Novita => &mut providers.novita, + }; + entry.api_key = Some(api_key); + } + + switch_provider(app, engine_handle, config, provider, None).await; +} + +fn apply_loaded_session(app: &mut App, session: &SavedSession) { + app.api_messages.clone_from(&session.messages); + app.clear_history(); + app.tool_cells.clear(); + app.tool_details_by_cell.clear(); + app.active_cell = None; + app.active_tool_details.clear(); + app.active_cell_revision = app.active_cell_revision.wrapping_add(1); + app.exploring_cell = None; + app.exploring_entries.clear(); + app.ignored_tool_calls.clear(); + app.pending_tool_uses.clear(); + app.last_exec_wait_command = None; + + let messages = app.api_messages.clone(); + let mut message_to_cell = std::collections::HashMap::new(); + for (message_index, msg) in messages.iter().enumerate() { + let mut cells = history_cells_from_message(msg); + if msg.role == "user" + && session + .context_references + .iter() + .any(|record| record.message_index == message_index) + { + for cell in &mut cells { + if let HistoryCell::User { content } = cell { + *content = compact_user_context_display(content); + } + } + } + let base = app.history.len(); + if msg.role == "user" + && let Some(offset) = cells + .iter() + .position(|cell| matches!(cell, HistoryCell::User { .. })) + { + message_to_cell.insert(message_index, base + offset); + } + app.extend_history(cells); + } + app.sync_context_references_from_session(&session.context_references, &message_to_cell); + app.mark_history_updated(); + app.transcript_selection.clear(); + app.model.clone_from(&session.metadata.model); + app.update_model_compaction_budget(); + app.workspace.clone_from(&session.metadata.workspace); + app.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX); + app.total_conversation_tokens = app.total_tokens; + app.last_prompt_tokens = None; + app.last_completion_tokens = None; + app.last_prompt_cache_hit_tokens = None; + app.last_prompt_cache_miss_tokens = None; + app.current_session_id = Some(session.metadata.id.clone()); + app.workspace_context = None; + app.workspace_context_refreshed_at = None; + if let Some(sp) = session.system_prompt.as_ref() { + app.system_prompt = Some(SystemPrompt::Text(sp.clone())); + } else { + app.system_prompt = None; + } + app.scroll_to_bottom(); +} + +fn compact_user_context_display(content: &str) -> String { + content + .split("\n\n---\n\nLocal context from @mentions:") + .next() + .unwrap_or(content) + .to_string() +} + +fn refresh_workspace_context_if_needed(app: &mut App, now: Instant, allow_blocking_refresh: bool) { + if app + .workspace_context_refreshed_at + .is_some_and(|refreshed_at| { + now.duration_since(refreshed_at) < Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS) + }) + { + return; + } + + if !allow_blocking_refresh { + return; + } + + app.workspace_context = collect_workspace_context(&app.workspace); + app.workspace_context_refreshed_at = Some(now); +} + +#[derive(Debug, Default, Clone, Copy)] +struct WorkspaceChangeSummary { + staged: usize, + modified: usize, + untracked: usize, + conflicts: usize, +} + +impl WorkspaceChangeSummary { + fn is_clean(&self) -> bool { + self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0 + } +} + +fn collect_workspace_context(workspace: &Path) -> Option { + let branch = workspace_git_branch(workspace)?; + let summary = workspace_git_change_summary(workspace)?; + + let mut parts = Vec::new(); + if summary.staged > 0 { + parts.push(format!("{} staged", summary.staged)); + } + if summary.modified > 0 { + parts.push(format!("{} modified", summary.modified)); + } + if summary.untracked > 0 { + parts.push(format!("{} untracked", summary.untracked)); + } + if summary.conflicts > 0 { + parts.push(format!("{} conflicts", summary.conflicts)); + } + + let status = if summary.is_clean() { + "clean".to_string() + } else { + parts.join(", ") + }; + + Some(format!("{branch} | {status}")) +} + +fn workspace_git_branch(workspace: &Path) -> Option { + let branch = run_git_query(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; + let branch = branch.trim().to_string(); + if branch == "HEAD" || branch.is_empty() { + let short_hash = run_git_query(workspace, &["rev-parse", "--short", "HEAD"]).ok()?; + let short_hash = short_hash.trim(); + if short_hash.is_empty() { + return None; + } + return Some(format!("detached:{short_hash}")); + } + Some(branch) +} + +fn workspace_git_change_summary(workspace: &Path) -> Option { + let status = run_git_query( + workspace, + &["status", "--short", "--untracked-files=normal"], + ) + .ok()?; + + if status.trim().is_empty() { + return Some(WorkspaceChangeSummary::default()); + } + + let mut summary = WorkspaceChangeSummary::default(); + for line in status.lines() { + if line.trim().is_empty() { + continue; + } + + let mut chars = line.chars(); + let staged = chars.next()?; + let modified = chars.next().unwrap_or(' '); + + if staged == ' ' && modified == ' ' { + continue; + } + if staged == '?' && modified == '?' { + summary.untracked = summary.untracked.saturating_add(1); + continue; + } + + if staged == 'U' || modified == 'U' { + summary.conflicts = summary.conflicts.saturating_add(1); + } + if staged != ' ' && staged != '?' { + summary.staged = summary.staged.saturating_add(1); + } + if modified != ' ' && modified != '?' { + summary.modified = summary.modified.saturating_add(1); + } + } + + Some(summary) +} + +fn run_git_query(workspace: &Path, args: &[&str]) -> std::io::Result { + let output = Command::new("git") + .args(args) + .current_dir(workspace) + .output()?; + if !output.status.success() { + return Err(std::io::Error::other("git command failed")); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn pause_terminal( + terminal: &mut Terminal>, + use_alt_screen: bool, + use_mouse_capture: bool, + use_bracketed_paste: bool, +) -> Result<()> { + disable_raw_mode()?; + if use_alt_screen { + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + } + if use_mouse_capture { + execute!(terminal.backend_mut(), DisableMouseCapture)?; + } + if use_bracketed_paste { + execute!(terminal.backend_mut(), DisableBracketedPaste)?; + } + Ok(()) +} + +fn resume_terminal( + terminal: &mut Terminal>, + use_alt_screen: bool, + use_mouse_capture: bool, + use_bracketed_paste: bool, +) -> Result<()> { + enable_raw_mode()?; + if use_alt_screen { + execute!(terminal.backend_mut(), EnterAlternateScreen)?; + } + if use_mouse_capture { + execute!(terminal.backend_mut(), EnableMouseCapture)?; + } + if use_bracketed_paste { + execute!(terminal.backend_mut(), EnableBracketedPaste)?; + } + terminal.clear()?; + Ok(()) +} + +fn status_color(level: StatusToastLevel) -> ratatui::style::Color { + match level { + StatusToastLevel::Info => palette::DEEPSEEK_SKY, + StatusToastLevel::Success => palette::STATUS_SUCCESS, + StatusToastLevel::Warning => palette::STATUS_WARNING, + StatusToastLevel::Error => palette::STATUS_ERROR, + } +} + +fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { + if area.width == 0 || area.height == 0 { + return; + } + + // Pull in the toast first so we don't re-borrow `app` mutably mid-build, + // then build the FooterProps once. The widget itself is a pure render — + // it owns no `App` knowledge; all width-aware layout lives in the widget. + // + // The quit-confirmation prompt takes precedence over normal status toasts + // because it represents a transient instruction the user must respond to + // within ~2s. Mirrors codex-rs's `FooterMode::QuitShortcutReminder`. + let quit_prompt = if app.quit_is_armed() { + Some(FooterToast { + text: "Press Ctrl+C again to quit".to_string(), + color: palette::STATUS_WARNING, + }) + } else { + None + }; + let toast = quit_prompt.or_else(|| { + app.active_status_toast().map(|toast| FooterToast { + text: toast.text, + color: status_color(toast.level), + }) + }); + + // Drive every cluster from the user's configured `status_items`. Mode + // and Model are always rendered by `FooterProps` itself (their position + // is structural — cluster gating is handled by the widget), so we only + // gate the optional clusters here. If a variant is missing from + // `status_items`, its span vec stays empty and the footer hides it. + let mut props = render_footer_from(app, &app.status_items, toast); + // FooterProps is mut so the working-strip animation can layer on top. + + // Animate the spacer between the left status line and the right-hand + // chips whenever a turn is live: model loading/streaming, compacting, or + // sub-agents in flight. Honors the `low_motion` setting — calm terminals + // get the plain whitespace gap. Strip frame counter ticks every 150 ms + // (crest A advances every 4 ticks ≈ 600 ms, B every 6 ticks ≈ 900 ms, + // jitter every 17 ticks ≈ 2.5 s). Dot-pulse counter ticks every 400 ms + // so `working` → `working...` reads at a calm pace. + if footer_working_strip_active(app) { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let dot_frame = now_ms / 400; + // Surface one compact live status row in the footer whenever a turn + // is live. Tool turns get the current action plus active/done counts; + // non-tool work falls back to the existing dot-pulse label. + props.state_label = active_tool_status_label(app) + .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame)); + props.state_color = palette::DEEPSEEK_SKY; + + // Spout drift: only animate when low_motion is off. The textual + // `working...` pulse stays even in low-motion mode so the user still + // sees that something is happening. + if !app.low_motion { + let strip_frame = now_ms / 150; + props.working_strip_frame = Some(strip_frame); + } + } else if props.state_label == "ready" + && let Some(label) = selected_detail_footer_label(app) + { + props.state_label = label; + props.state_color = palette::TEXT_MUTED; + } + + let widget = FooterWidget::new(props); + let buf = f.buffer_mut(); + widget.render(area, buf); +} + +/// Whether the footer should animate the water-spout strip. Driven by the +/// underlying live-work flags so the strip stays visible for the *entire* +/// turn — not just the moments where bytes are streaming. `is_loading` can +/// flicker off between LLM rounds within a single turn (tool execution, +/// reasoning replay, capacity refresh, etc.), so we ALSO gate on the turn +/// itself still being in flight via `runtime_turn_status == "in_progress"`. +/// Without that, the user sees the strip vanish for seconds at a time even +/// though the agent is still working. +fn footer_working_strip_active(app: &App) -> bool { + let turn_in_progress = app.runtime_turn_status.as_deref() == Some("in_progress"); + app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress +} + +#[derive(Default)] +struct ActiveToolStatusSnapshot { + primary_running: Option, + primary_any: Option, + running: usize, + completed: usize, + started_at: Option, +} + +impl ActiveToolStatusSnapshot { + fn record(&mut self, label: String, status: ToolStatus, started_at: Option) { + if self.primary_any.is_none() { + self.primary_any = Some(label.clone()); + } + if status == ToolStatus::Running { + self.running += 1; + if self.primary_running.is_none() { + self.primary_running = Some(label); + } + } else { + self.completed += 1; + } + if let Some(started) = started_at { + self.started_at = Some(match self.started_at { + Some(current) => current.min(started), + None => started, + }); + } + } + + fn total(&self) -> usize { + self.running + self.completed + } +} + +fn active_tool_status_label(app: &App) -> Option { + let active = app.active_cell.as_ref()?; + if active.is_empty() { + return None; + } + + let mut snapshot = ActiveToolStatusSnapshot::default(); + for cell in active.entries() { + collect_active_tool_status(cell, &mut snapshot); + } + if snapshot.total() == 0 { + return None; + } + + let primary = snapshot + .primary_running + .or(snapshot.primary_any) + .unwrap_or_else(|| "tools".to_string()); + let primary = truncate_line_to_width(&primary, 30); + let elapsed = snapshot + .started_at + .or(app.turn_started_at) + .map(|started| format!("{}s", started.elapsed().as_secs())); + + let mut parts = vec![ + primary, + format!("{} active", snapshot.running), + format!("{} done", snapshot.completed), + ]; + if let Some(elapsed) = elapsed { + parts.push(elapsed); + } + parts.push("Alt+V".to_string()); + Some(parts.join(" \u{00B7} ")) +} + +fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) { + let HistoryCell::Tool(tool) = cell else { + return; + }; + match tool { + ToolCell::Exec(exec) => snapshot.record( + format!("run {}", one_line_summary(&exec.command, 80)), + exec.status, + exec.started_at, + ), + ToolCell::Exploring(explore) => { + for entry in &explore.entries { + snapshot.record( + format!("read {}", one_line_summary(&entry.label, 80)), + entry.status, + None, + ); + } + } + ToolCell::PlanUpdate(plan) => { + snapshot.record("update plan".to_string(), plan.status, None); + } + ToolCell::PatchSummary(patch) => { + snapshot.record(format!("patch {}", patch.path), patch.status, None); + } + ToolCell::Review(review) => { + let target = one_line_summary(&review.target, 80); + let label = if target.is_empty() { + "review".to_string() + } else { + format!("review {target}") + }; + snapshot.record(label, review.status, None); + } + ToolCell::DiffPreview(diff) => { + snapshot.record(format!("diff {}", diff.title), ToolStatus::Success, None); + } + ToolCell::Mcp(mcp) => snapshot.record(format!("tool {}", mcp.tool), mcp.status, None), + ToolCell::ViewImage(image) => snapshot.record( + format!("image {}", image.path.display()), + ToolStatus::Success, + None, + ), + ToolCell::WebSearch(search) => { + snapshot.record(format!("search {}", search.query), search.status, None); + } + ToolCell::Generic(generic) => { + snapshot.record(format!("tool {}", generic.name), generic.status, None); + } + } +} + +fn one_line_summary(text: &str, max_width: usize) -> String { + truncate_line_to_width( + &text.split_whitespace().collect::>().join(" "), + max_width, + ) +} + +/// Build [`FooterProps`] from a user-configured `status_items` slice. +/// +/// Variants are routed to their structural cluster: `Mode` and `Model` are +/// always emitted (the widget needs them to lay out the line correctly even +/// when the user toggled them off the picker — we honour the toggle by +/// blanking their visible content rather than collapsing the layout). +/// `Cost` and `Status` belong in the left cluster; the rest in the right. +/// +/// A variant absent from `items` produces an empty span vec, which the +/// footer widget already hides cleanly. This keeps the renderer fully +/// data-driven without changing `FooterProps`'s public shape. +fn render_footer_from( + app: &App, + items: &[crate::config::StatusItem], + toast: Option, +) -> FooterProps { + use crate::config::StatusItem as S; + let has = |item: S| items.contains(&item); + + let (state_label, state_color) = if has(S::Status) { + footer_state_label(app) + } else { + // "ready" is the sentinel the widget uses to skip the status segment; + // pair it with TEXT_MUTED for visual neutrality. + ("ready", palette::TEXT_MUTED) + }; + + let coherence = if has(S::Coherence) { + footer_coherence_spans(app) + } else { + Vec::new() + }; + let agents = if has(S::Agents) { + crate::tui::widgets::footer_agents_chip(running_agent_count(app)) + } else { + Vec::new() + }; + let reasoning_replay = if has(S::ReasoningReplay) { + footer_reasoning_replay_spans(app) + } else { + Vec::new() + }; + let cache = if has(S::Cache) { + footer_cache_spans(app) + } else { + Vec::new() + }; + let cost = if has(S::Cost) && app.session_cost > 0.001 { + vec![Span::styled( + format!("${:.2}", app.session_cost), + Style::default().fg(palette::TEXT_MUTED), + )] + } else { + Vec::new() + }; + + // Build the props; `Mode` and `Model` toggles modulate downstream by + // blanking the rendered text rather than restructuring the widget — the + // user is opting out of the chip, not destroying the bar. + let mut props = FooterProps::from_app( + app, + toast, + state_label, + state_color, + coherence, + agents, + reasoning_replay, + cache, + cost, + ); + if !has(S::Mode) { + props.mode_label = ""; + } + if !has(S::Model) { + props.model.clear(); + } + + // Right-cluster extension chips: append in `items` order so user + // ordering is preserved across the new variants. + let mut extra: Vec> = Vec::new(); + for item in items { + let chip = match *item { + S::ContextPercent => footer_context_percent_spans(app), + S::GitBranch | S::LastToolElapsed | S::RateLimit => Vec::new(), + _ => continue, + }; + if chip.is_empty() { + continue; + } + if !extra.is_empty() { + extra.push(Span::raw(" ")); + } + extra.extend(chip); + } + if !extra.is_empty() { + // Stack into the cache slot — last existing right-cluster pipe — so + // they appear adjacent without changing FooterProps's API. Keep + // existing cache spans first so cache hit rate stays before the + // user-added extras. + if !props.cache.is_empty() { + props.cache.push(Span::raw(" ")); + } + props.cache.extend(extra); + } + + props +} + +/// Spans for the "context %" footer chip. Mirrors the header colour ramp so +/// the two surfaces stay visually consistent when both are enabled. +fn footer_context_percent_spans(app: &App) -> Vec> { + let Some((_, _, percent)) = context_usage_snapshot(app) else { + return Vec::new(); + }; + let color = if percent >= 95.0 { + palette::STATUS_ERROR + } else if percent >= 85.0 { + palette::STATUS_WARNING + } else { + palette::TEXT_MUTED + }; + vec![Span::styled( + format!("ctx {percent:.0}%"), + Style::default().fg(color), + )] +} + +/// Test-only helper retained as a parity reference for `FooterWidget`'s +/// auxiliary-span composition. Production rendering is performed by the +/// widget itself; the existing footer parity tests still exercise this +/// function directly to guard against drift. +#[allow(dead_code)] +fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { + // Context % is already shown in the header signal bar — don't + // duplicate it in the footer. The footer carries unique info only: + // coherence, in-flight sub-agents, reasoning replay tokens, cache hit + // rate, and session cost. + let coherence_spans = footer_coherence_spans(app); + let agents_spans = crate::tui::widgets::footer_agents_chip(running_agent_count(app)); + let replay_spans = footer_reasoning_replay_spans(app); + let cache_spans = footer_cache_spans(app); + let cost_spans = if app.session_cost > 0.001 { + vec![Span::styled( + format!("${:.2}", app.session_cost), + Style::default().fg(palette::TEXT_MUTED), + )] + } else { + Vec::new() + }; + + let parts: Vec<&Vec>> = [ + &coherence_spans, + &agents_spans, + &replay_spans, + &cache_spans, + &cost_spans, + ] + .iter() + .filter(|spans| !spans.is_empty()) + .copied() + .collect(); + + // Try to fit as many parts as possible, dropping from the end. + for end in (0..=parts.len()).rev() { + let mut combined = Vec::new(); + for (i, part) in parts[..end].iter().enumerate() { + if i > 0 { + combined.push(Span::raw(" ")); + } + combined.extend(part.iter().cloned()); + } + if spans_width(&combined) <= max_width { + return combined; + } + } + Vec::new() +} + +fn footer_coherence_spans(app: &App) -> Vec> { + // Only surface coherence when the engine is actively intervening — the + // user-facing signal is "we're doing something different now," not + // "your conversation is getting complex," which the context-percent + // header already covers. `GettingCrowded` is just a soft hint, so we + // suppress it; the active interventions get their own visible label. + let (label, color) = match app.coherence_state { + CoherenceState::Healthy | CoherenceState::GettingCrowded => return Vec::new(), + CoherenceState::RefreshingContext => ("refreshing context", palette::STATUS_WARNING), + CoherenceState::VerifyingRecentWork => ("verifying", palette::DEEPSEEK_SKY), + CoherenceState::ResettingPlan => ("resetting plan", palette::STATUS_ERROR), + }; + + vec![Span::styled(label.to_string(), Style::default().fg(color))] +} + +fn footer_cache_spans(app: &App) -> Vec> { + let Some(hit_tokens) = app.last_prompt_cache_hit_tokens else { + return Vec::new(); + }; + let miss_tokens = app.last_prompt_cache_miss_tokens.unwrap_or(0); + let total = hit_tokens.saturating_add(miss_tokens); + if total == 0 { + return Vec::new(); + } + + let percent = (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0); + vec![Span::styled( + format!("cache {:.0}%", percent), + Style::default().fg(palette::TEXT_MUTED), + )] +} + +/// Render a footer chip showing the size of the `reasoning_content` block +/// replayed on the most recent thinking-mode tool-calling turn (#30). +/// +/// Stays hidden when the count is zero (non-thinking models, first turn, or +/// turns with no tool calls). When replay tokens dominate the input budget +/// (>50%), the chip turns warning-coloured so users notice that thinking +/// replay is the main consumer of context. +fn footer_reasoning_replay_spans(app: &App) -> Vec> { + let Some(replay) = app.last_reasoning_replay_tokens else { + return Vec::new(); + }; + if replay == 0 { + return Vec::new(); + } + let label = format!("rsn {}", format_token_count_compact(u64::from(replay))); + let color = match app.last_prompt_tokens { + Some(input) if input > 0 && f64::from(replay) / f64::from(input) > 0.5 => { + palette::STATUS_WARNING + } + _ => palette::TEXT_MUTED, + }; + vec![Span::styled(label, Style::default().fg(color))] +} + +#[allow(dead_code)] +fn footer_toast_spans( + toast: &crate::tui::app::StatusToast, + max_width: usize, +) -> Vec> { + let truncated = truncate_line_to_width(&toast.text, max_width.max(1)); + vec![Span::styled( + truncated, + Style::default().fg(status_color(toast.level)), + )] +} + +#[allow(dead_code)] +fn footer_status_line_spans(app: &App, max_width: usize) -> Vec> { + if max_width == 0 { + return Vec::new(); + } + + let (mode_label, mode_color) = footer_mode_style(app); + let (status_label, status_color) = footer_state_label(app); + let sep = " \u{00B7} "; + let show_status = status_label != "ready"; + + let fixed_width = mode_label.width() + + sep.width() + + if show_status { + sep.width() + status_label.width() + } else { + 0 + }; + + if max_width <= mode_label.width() { + return vec![Span::styled( + truncate_line_to_width(mode_label, max_width), + Style::default().fg(mode_color), + )]; + } + + let model_budget = max_width.saturating_sub(fixed_width).max(1); + let model_label = truncate_line_to_width(&app.model, model_budget); + + let mut spans = vec![ + Span::styled(mode_label.to_string(), Style::default().fg(mode_color)), + Span::styled(sep.to_string(), Style::default().fg(palette::TEXT_DIM)), + Span::styled(model_label, Style::default().fg(palette::TEXT_HINT)), + ]; + + if show_status { + spans.push(Span::styled( + sep.to_string(), + Style::default().fg(palette::TEXT_DIM), + )); + spans.push(Span::styled( + status_label.to_string(), + Style::default().fg(status_color), + )); + } + + spans +} + +fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) { + if app.is_compacting { + return ("compacting \u{238B}", palette::STATUS_WARNING); + } + // Note: we deliberately do NOT show a "thinking" label for `is_loading`. + // The animated water-spout strip in the footer's spacer is the visual + // signal that the model is live; "thinking" was misleading because it + // fired for every kind of in-flight work (tool calls, streaming, etc.), + // not strictly reasoning. Sub-agents still surface "working" because + // that's a distinct lifecycle the user can act on (open `/agents`). + if running_agent_count(app) > 0 { + return ("working", palette::DEEPSEEK_SKY); + } + if app.queued_draft.is_some() { + return ("draft", palette::TEXT_MUTED); + } + + if !app.view_stack.is_empty() { + return ("overlay", palette::TEXT_MUTED); + } + + if !app.input.is_empty() { + return ("draft", palette::TEXT_MUTED); + } + + ("ready", palette::TEXT_MUTED) +} + +#[allow(dead_code)] +fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) { + let label = app.mode.as_setting(); + let color = match app.mode { + crate::tui::app::AppMode::Agent => palette::MODE_AGENT, + crate::tui::app::AppMode::Yolo => palette::MODE_YOLO, + crate::tui::app::AppMode::Plan => palette::MODE_PLAN, + }; + (label, color) +} + +fn format_token_count_compact(tokens: u64) -> String { + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}k", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +#[allow(dead_code)] +fn format_context_budget(used: i64, max: u32) -> String { + let max_u64 = u64::from(max); + let max_i64 = i64::from(max); + + if used > max_i64 { + return format!( + ">{}/{}", + format_token_count_compact(max_u64), + format_token_count_compact(max_u64) + ); + } + + let used_u64 = u64::try_from(used.max(0)).unwrap_or(0); + format!( + "{}/{}", + format_token_count_compact(used_u64), + format_token_count_compact(max_u64) + ) +} + +#[allow(dead_code)] +fn spans_width(spans: &[Span<'_>]) -> usize { + spans.iter().map(|span| span.content.width()).sum() +} + +#[allow(dead_code)] +fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option { + if total <= visible { + return None; + } + + let max_top = total.saturating_sub(visible); + if max_top == 0 { + return None; + } + + let clamped_top = top.min(max_top); + let percent = ((clamped_top as f64 / max_top as f64) * 100.0).round() as u16; + Some(percent.min(100)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SearchDirection { + Forward, + Backward, +} + +fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool { + let line_meta = app.transcript_cache.line_meta(); + if line_meta.is_empty() { + return false; + } + + let top = app + .last_transcript_top + .min(line_meta.len().saturating_sub(1)); + let current_cell = line_meta + .get(top) + .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line) + .map(|(cell_index, _)| cell_index); + + let mut scan_indices = Vec::new(); + match direction { + SearchDirection::Forward => { + scan_indices.extend((top.saturating_add(1))..line_meta.len()); + } + SearchDirection::Backward => { + scan_indices.extend((0..top).rev()); + } + } + + for idx in scan_indices { + let Some((cell_index, _)) = line_meta[idx].cell_line() else { + continue; + }; + if current_cell.is_some_and(|current| current == cell_index) { + continue; + } + if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) { + continue; + } + if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) { + app.transcript_scroll = anchor; + app.pending_scroll_delta = 0; + app.needs_redraw = true; + return true; + } + } + + false +} + +fn estimated_context_tokens(app: &App) -> Option { + i64::try_from(estimate_input_tokens_conservative( + &app.api_messages, + app.system_prompt.as_ref(), + )) + .ok() +} + +fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> { + let max = context_window_for_model(&app.model)?; + let max_i64 = i64::from(max); + let reported = app + .last_prompt_tokens + .map(i64::from) + .map(|tokens| tokens.max(0)); + let estimated = estimated_context_tokens(app).map(|tokens| tokens.max(0)); + + // Always prefer the estimated current-context size (computed from + // `app.api_messages`) when we have it. Reported `last_prompt_tokens` + // comes from `Event::TurnComplete.usage`, which the engine builds with + // `turn.add_usage` — that SUMS input_tokens across every round in the + // turn, so a multi-round tool-call turn reports a value much larger + // than the actual context window state, then the next single-round + // turn drops back to a single round's input_tokens. User-visible % + // was bouncing 31% → 9% (#115) because of this. The estimate is + // monotonic wrt conversation growth, which is what a "context filling + // up" indicator should show. We still consult `reported` only as a + // fallback when no estimate is available (e.g., immediately after a + // session restore before the api_messages are populated). + let used = match (estimated, reported) { + (Some(estimated), _) => estimated.min(max_i64), + (None, Some(reported)) => reported.min(max_i64), + (None, None) => return None, + }; + + let max_f64 = f64::from(max); + let used_f64 = used as f64; + let percent = ((used_f64 / max_f64) * 100.0).clamp(0.0, 100.0); + Some((used, max, percent)) +} + +/// Retained as a callable utility — `context_usage_snapshot` no longer uses +/// it directly (#115 makes the estimate the primary signal), but tests in +/// `ui/tests.rs` still exercise it and a future heuristic may want to +/// distinguish "obviously inflated reported tokens" from healthy reports. +#[allow(dead_code)] +fn is_reported_context_inflated(reported: i64, estimated: i64) -> bool { + const MIN_ABSOLUTE_GAP: i64 = 4_096; + if estimated <= 0 || reported <= estimated { + return false; + } + + reported.saturating_sub(estimated) >= MIN_ABSOLUTE_GAP + && reported >= estimated.saturating_mul(4) +} + +fn maybe_warn_context_pressure(app: &mut App) { + let Some((used, max, percent)) = context_usage_snapshot(app) else { + return; + }; + + if percent < CONTEXT_WARNING_THRESHOLD_PERCENT { + return; + } + + let recommendation = if app.auto_compact { + "Auto-compaction is enabled." + } else { + "Consider /compact or /clear." + }; + + if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { + app.status_message = Some(format!( + "Context critical: {:.0}% ({used}/{max} tokens). {recommendation}", + percent + )); + return; + } + + if app.status_message.is_none() { + app.status_message = Some(format!( + "Context high: {:.0}% ({used}/{max} tokens). {recommendation}", + percent + )); + } +} + +fn should_auto_compact_before_send(app: &App) -> bool { + if !app.auto_compact { + return false; + } + context_usage_snapshot(app) + .map(|(_, _, pct)| pct >= CONTEXT_CRITICAL_THRESHOLD_PERCENT) + .unwrap_or(false) +} + +fn status_animation_interval_ms(app: &App) -> u64 { + if app.low_motion { + 2_400 + } else { + UI_STATUS_ANIMATION_MS + } +} + +fn active_poll_ms(app: &App) -> u64 { + if app.low_motion { + 96 + } else { + UI_ACTIVE_POLL_MS + } +} + +fn idle_poll_ms(app: &App) -> u64 { + if app.low_motion { 120 } else { UI_IDLE_POLL_MS } +} + +fn history_has_live_motion(history: &[HistoryCell]) -> bool { + use crate::tui::history::SubAgentCell; + use crate::tui::widgets::agent_card::AgentLifecycle; + history.iter().any(|cell| match cell { + HistoryCell::Thinking { streaming, .. } => *streaming, + HistoryCell::Tool(tool) => match tool { + ToolCell::Exec(cell) => cell.status == ToolStatus::Running, + ToolCell::Exploring(cell) => cell + .entries + .iter() + .any(|entry| entry.status == ToolStatus::Running), + ToolCell::PlanUpdate(cell) => cell.status == ToolStatus::Running, + ToolCell::PatchSummary(cell) => cell.status == ToolStatus::Running, + ToolCell::Review(cell) => cell.status == ToolStatus::Running, + ToolCell::DiffPreview(_) => false, + ToolCell::Mcp(cell) => cell.status == ToolStatus::Running, + ToolCell::ViewImage(_) => false, + ToolCell::WebSearch(cell) => cell.status == ToolStatus::Running, + ToolCell::Generic(cell) => cell.status == ToolStatus::Running, + }, + HistoryCell::SubAgent(SubAgentCell::Delegate(card)) => matches!( + card.status, + AgentLifecycle::Pending | AgentLifecycle::Running + ), + HistoryCell::SubAgent(SubAgentCell::Fanout(card)) => card + .workers + .iter() + .any(|w| matches!(w.status, AgentLifecycle::Pending | AgentLifecycle::Running)), + _ => false, + }) +} + +pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(text) <= max_width { + return text.to_string(); + } + // For very small budgets, take chars until we exceed the *display* width. + // Counting characters instead of widths (the previous behavior) overran + // the budget for any double-width grapheme and contributed to mid-character + // sidebar artifacts on resize (issue #65). + if max_width <= 3 { + let mut out = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > max_width { + break; + } + out.push(ch); + width += ch_width; + } + return out; + } + + let mut out = String::new(); + let mut width = 0usize; + let limit = max_width.saturating_sub(3); + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > limit { + break; + } + out.push(ch); + width += ch_width; + } + out.push_str("..."); + out +} + +fn handle_mouse_event(app: &mut App, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::ScrollUp => { + let update = app.mouse_scroll.on_scroll(ScrollDirection::Up); + app.pending_scroll_delta += update.delta_lines; + } + MouseEventKind::ScrollDown => { + let update = app.mouse_scroll.on_scroll(ScrollDirection::Down); + app.pending_scroll_delta += update.delta_lines; + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(point) = selection_point_from_mouse(app, mouse) { + app.transcript_selection.anchor = Some(point); + app.transcript_selection.head = Some(point); + app.transcript_selection.dragging = true; + + if app.is_loading + && app.transcript_scroll.is_at_tail() + && let Some(anchor) = TranscriptScroll::anchor_for( + app.transcript_cache.line_meta(), + app.last_transcript_top, + ) + { + app.transcript_scroll = anchor; + } + } else if app.transcript_selection.is_active() { + app.transcript_selection.clear(); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if app.transcript_selection.dragging + && let Some(point) = selection_point_from_mouse(app, mouse) + { + app.transcript_selection.head = Some(point); + } + } + MouseEventKind::Up(MouseButton::Left) if app.transcript_selection.dragging => { + app.transcript_selection.dragging = false; + if selection_has_content(app) { + copy_active_selection(app); + } + } + _ => {} + } +} + +fn selection_point_from_mouse(app: &App, mouse: MouseEvent) -> Option { + selection_point_from_position( + app.last_transcript_area?, + mouse.column, + mouse.row, + app.last_transcript_top, + app.last_transcript_total, + app.last_transcript_padding_top, + ) +} + +fn selection_point_from_position( + area: Rect, + column: u16, + row: u16, + transcript_top: usize, + transcript_total: usize, + padding_top: usize, +) -> Option { + if column < area.x + || column >= area.x + area.width + || row < area.y + || row >= area.y + area.height + { + return None; + } + + if transcript_total == 0 { + return None; + } + + let row = row.saturating_sub(area.y) as usize; + if row < padding_top { + return None; + } + let row = row.saturating_sub(padding_top); + + let col = column.saturating_sub(area.x) as usize; + let line_index = transcript_top + .saturating_add(row) + .min(transcript_total.saturating_sub(1)); + + Some(TranscriptSelectionPoint { + line_index, + column: col, + }) +} + +fn selection_has_content(app: &App) -> bool { + match app.transcript_selection.ordered_endpoints() { + Some((start, end)) => start != end, + None => false, + } +} + +fn copy_active_selection(app: &mut App) { + if !app.transcript_selection.is_active() { + return; + } + if let Some(text) = selection_to_text(app) { + if app.clipboard.write_text(&text).is_ok() { + app.status_message = Some("Selection copied".to_string()); + } else { + app.status_message = Some("Copy failed".to_string()); + } + } +} + +fn selection_to_text(app: &App) -> Option { + let (start, end) = app.transcript_selection.ordered_endpoints()?; + let lines = app.transcript_cache.lines(); + if lines.is_empty() { + return None; + } + let end_index = end.line_index.min(lines.len().saturating_sub(1)); + let start_index = start.line_index.min(end_index); + + let mut out = String::new(); + #[allow(clippy::needless_range_loop)] + for line_index in start_index..=end_index { + let line_text = line_to_plain(&lines[line_index]); + let slice = if start_index == end_index { + slice_text(&line_text, start.column, end.column) + } else if line_index == start_index { + slice_text(&line_text, start.column, text_display_width(&line_text)) + } else if line_index == end_index { + slice_text(&line_text, 0, end.column) + } else { + line_text + }; + out.push_str(&slice); + if line_index != end_index { + out.push('\n'); + } + } + Some(out) +} + +fn open_pager_for_selection(app: &mut App) -> bool { + let Some(text) = selection_to_text(app) else { + return false; + }; + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2)); + app.view_stack.push(pager); + true +} + +fn open_pager_for_last_message(app: &mut App) -> bool { + let Some(cell) = app.history.last() else { + return false; + }; + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let text = history_cell_to_text(cell, width); + let pager = PagerView::from_text("Message", &text, width.saturating_sub(2)); + app.view_stack.push(pager); + true +} + +/// Open a pager showing the full thinking block. Targets the cell at the +/// current selection if it's a Thinking cell; otherwise falls back to the +/// most recent Thinking cell in history. Bound to Ctrl+O so users can read +/// reasoning content that's been collapsed in calm-mode rendering. +fn open_thinking_pager(app: &mut App) -> bool { + let selected_cell = app + .transcript_selection + .ordered_endpoints() + .and_then(|(start, _)| { + app.transcript_cache + .line_meta() + .get(start.line_index) + .and_then(|meta| meta.cell_line()) + .map(|(cell_index, _)| cell_index) + }) + .filter(|&idx| { + matches!( + app.history.get(idx), + Some(crate::tui::history::HistoryCell::Thinking { .. }) + ) + }); + + let target_idx = selected_cell.or_else(|| { + app.history + .iter() + .enumerate() + .rev() + .find_map(|(idx, cell)| { + if matches!(cell, crate::tui::history::HistoryCell::Thinking { .. }) { + Some(idx) + } else { + None + } + }) + }); + + let Some(idx) = target_idx else { + app.status_message = Some("No thinking blocks to expand".to_string()); + return true; + }; + + let cell = &app.history[idx]; + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let text = history_cell_to_text(cell, width); + app.view_stack.push(PagerView::from_text( + "Thinking", + &text, + width.saturating_sub(2), + )); + true +} + +fn open_tool_details_pager(app: &mut App) -> bool { + let target_cell = detail_target_cell_index(app); + + let Some(cell_index) = target_cell else { + return false; + }; + if let Some(detail) = app.tool_detail_record_for_cell(cell_index) { + let input = serde_json::to_string_pretty(&detail.input) + .unwrap_or_else(|_| detail.input.to_string()); + let output = detail.output.as_deref().map_or( + "(not available)".to_string(), + std::string::ToString::to_string, + ); + let content = format!( + "Tool ID: {}\nTool: {}\n\nInput:\n{}\n\nOutput:\n{}", + detail.tool_id, detail.tool_name, input, output + ); + + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + app.view_stack.push(PagerView::from_text( + format!("Tool: {}", detail.tool_name), + &content, + width.saturating_sub(2), + )); + return true; + } + + let Some(cell) = app.cell_at_virtual_index(cell_index) else { + app.status_message = Some("No details available for the selected line".to_string()); + return false; + }; + let title = match cell { + HistoryCell::User { .. } => "You".to_string(), + HistoryCell::Assistant { .. } => "Assistant".to_string(), + HistoryCell::System { .. } => "Note".to_string(), + HistoryCell::Error { .. } => "Error".to_string(), + HistoryCell::Thinking { .. } => "Reasoning".to_string(), + HistoryCell::Tool(_) => "Message".to_string(), + HistoryCell::SubAgent(_) => "Sub-agent".to_string(), + }; + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + let content = history_cell_to_text(cell, width); + app.view_stack.push(PagerView::from_text( + title, + &content, + width.saturating_sub(2), + )); + true +} + +fn detail_target_cell_index(app: &App) -> Option { + if let Some((start, _)) = app.transcript_selection.ordered_endpoints() { + return app + .transcript_cache + .line_meta() + .get(start.line_index) + .and_then(|meta| meta.cell_line()) + .map(|(cell_index, _)| cell_index); + } + + app.detail_cell_index_for_viewport( + app.last_transcript_top, + app.last_transcript_visible.max(1), + app.transcript_cache.line_meta(), + ) + .or_else(|| app.history.len().checked_sub(1)) +} + +fn selected_detail_footer_label(app: &App) -> Option { + if app.transcript_selection.is_active() { + return None; + } + let cell_index = app.detail_cell_index_for_viewport( + app.last_transcript_top, + app.last_transcript_visible.max(1), + app.transcript_cache.line_meta(), + )?; + let label = detail_target_label(app, cell_index)?; + Some(format!( + "Alt+V details: {}", + truncate_line_to_width(&label, 34) + )) +} + +fn detail_target_label(app: &App, cell_index: usize) -> Option { + if let Some(detail) = app.tool_detail_record_for_cell(cell_index) { + return Some(detail.tool_name.clone()); + } + let cell = app.cell_at_virtual_index(cell_index)?; + match cell { + HistoryCell::Tool(ToolCell::Exec(exec)) => { + Some(format!("run {}", one_line_summary(&exec.command, 80))) + } + HistoryCell::Tool(ToolCell::Exploring(explore)) => Some(format!( + "workspace {} item{}", + explore.entries.len(), + if explore.entries.len() == 1 { "" } else { "s" } + )), + HistoryCell::Tool(ToolCell::PlanUpdate(_)) => Some("update plan".to_string()), + HistoryCell::Tool(ToolCell::PatchSummary(patch)) => Some(format!("patch {}", patch.path)), + HistoryCell::Tool(ToolCell::Review(review)) => { + let target = one_line_summary(&review.target, 80); + Some(if target.is_empty() { + "review".to_string() + } else { + format!("review {target}") + }) + } + HistoryCell::Tool(ToolCell::DiffPreview(diff)) => Some(format!("diff {}", diff.title)), + HistoryCell::Tool(ToolCell::Mcp(mcp)) => Some(format!("tool {}", mcp.tool)), + HistoryCell::Tool(ToolCell::ViewImage(image)) => { + Some(format!("image {}", image.path.display())) + } + HistoryCell::Tool(ToolCell::WebSearch(search)) => Some(format!("search {}", search.query)), + HistoryCell::Tool(ToolCell::Generic(generic)) => Some(format!("tool {}", generic.name)), + HistoryCell::SubAgent(_) => Some("sub-agent".to_string()), + _ => None, + } +} + +fn is_copy_shortcut(key: &KeyEvent) -> bool { + let is_c = matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')); + if !is_c { + return false; + } + + if key.modifiers.contains(KeyModifiers::SUPER) { + return true; + } + + key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) +} + +fn details_shortcut_modifiers(modifiers: KeyModifiers) -> bool { + modifiers.is_empty() + || modifiers == KeyModifiers::SHIFT + || (modifiers.contains(KeyModifiers::ALT) + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::SUPER)) +} + +fn is_paste_shortcut(key: &KeyEvent) -> bool { + let is_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V')); + if !is_v { + return false; + } + + // Cmd+V on macOS + if key.modifiers.contains(KeyModifiers::SUPER) { + return true; + } + + // Ctrl+V on Linux/Windows + key.modifiers.contains(KeyModifiers::CONTROL) +} + +fn should_scroll_with_arrows(_app: &App) -> bool { + false +} + +fn extract_reasoning_header(text: &str) -> Option { + let start = text.find("**")?; + let rest = &text[start + 2..]; + let end = rest.find("**")?; + let header = rest[..end].trim().trim_end_matches(':'); + if header.is_empty() { + None + } else { + Some(header.to_string()) + } +} + +fn subagent_status_rank(status: &SubAgentStatus) -> u8 { + match status { + SubAgentStatus::Running => 0, + SubAgentStatus::Interrupted(_) => 1, + SubAgentStatus::Failed(_) => 2, + SubAgentStatus::Completed => 3, + SubAgentStatus::Cancelled => 4, + } +} + +fn sort_subagents_in_place(agents: &mut [SubAgentResult]) { + agents.sort_by(|a, b| { + subagent_status_rank(&a.status) + .cmp(&subagent_status_rank(&b.status)) + .then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str())) + .then_with(|| a.agent_id.cmp(&b.agent_id)) + }); +} + +/// Route a `MailboxMessage` envelope to the matching in-transcript card, +/// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128). +fn handle_subagent_mailbox(app: &mut App, _seq: u64, message: &MailboxMessage) { + use crate::tui::history::{HistoryCell, SubAgentCell}; + use crate::tui::widgets::agent_card::{ + DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout, + }; + + // Resolve (or allocate) the target cell for this envelope. ChildSpawned + // is special — it always belongs to the active fanout card if one + // exists; otherwise it seeds a new one. + let agent_id = message.agent_id().to_string(); + + if matches!(message, MailboxMessage::ChildSpawned { .. }) + && let Some(idx) = app.last_fanout_card_index + && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) + { + apply_to_fanout(card, message); + app.subagent_card_index.insert(agent_id, idx); + app.mark_history_updated(); + return; + } + + // Existing card for this agent_id? Mutate in place. + if let Some(&idx) = app.subagent_card_index.get(&agent_id) { + let updated = match app.history.get_mut(idx) { + Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => { + apply_to_delegate(card, message) + } + Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => { + apply_to_fanout(card, message) + } + _ => false, + }; + if updated { + app.mark_history_updated(); + } + return; + } + + // No existing card — only `Started` reasonably opens one. Anything else + // for an unknown agent_id is dropped (likely arrived after the cell was + // cleared, e.g. session-resume edge cases). + let MailboxMessage::Started { agent_type, .. } = message else { + return; + }; + + let dispatch_kind = app.pending_subagent_dispatch.as_deref(); + let is_fanout = matches!( + dispatch_kind, + Some("agent_swarm" | "spawn_agents_on_csv" | "rlm") + ); + + if is_fanout { + // Reuse the active fanout card for sibling spawns; otherwise create + // one anchored at this position so subsequent siblings join it. + if let Some(idx) = app.last_fanout_card_index + && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = + app.history.get_mut(idx) + { + card.upsert_worker( + &agent_id, + crate::tui::widgets::agent_card::AgentLifecycle::Running, + ); + app.subagent_card_index.insert(agent_id, idx); + } else { + let mut card = FanoutCard::new(dispatch_kind.unwrap_or("fanout").to_string()); + card.upsert_worker( + &agent_id, + crate::tui::widgets::agent_card::AgentLifecycle::Running, + ); + app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); + let idx = app.history.len().saturating_sub(1); + app.last_fanout_card_index = Some(idx); + app.subagent_card_index.insert(agent_id, idx); + } + } else { + let card = DelegateCard::new(agent_id.clone(), agent_type.clone()); + app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card))); + let idx = app.history.len().saturating_sub(1); + app.subagent_card_index.insert(agent_id, idx); + // Single delegate consumes the pending dispatch label so a follow-on + // tool call doesn't accidentally inherit it. + app.pending_subagent_dispatch = None; + } + + app.mark_history_updated(); +} + +fn task_mode_label(mode: AppMode) -> &'static str { + mode.as_setting() +} + +fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry { + TaskPanelEntry { + id: summary.id, + status: task_status_label(summary.status).to_string(), + prompt_summary: summary.prompt_summary, + duration_ms: summary.duration_ms, + } +} + +fn task_status_label(status: TaskStatus) -> &'static str { + match status { + TaskStatus::Queued => "queued", + TaskStatus::Running => "running", + TaskStatus::Completed => "completed", + TaskStatus::Failed => "failed", + TaskStatus::Canceled => "canceled", + } +} + +fn format_task_list(tasks: &[TaskSummary]) -> String { + if tasks.is_empty() { + return "No tasks found.".to_string(); + } + + let mut lines = vec![ + format!("Tasks ({})", tasks.len()), + "----------------------------------------".to_string(), + ]; + for task in tasks { + let duration = task + .duration_ms + .map(|ms| format!("{:.2}s", ms as f64 / 1000.0)) + .unwrap_or_else(|| "-".to_string()); + lines.push(format!( + "{} {:9} {} {}", + task.id, + task_status_label(task.status), + duration, + task.prompt_summary + )); + } + lines.push("Use /task show for timeline details.".to_string()); + lines.join("\n") +} + +fn open_task_pager(app: &mut App, task: &TaskRecord) { + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(100) + .saturating_sub(4); + app.view_stack.push(PagerView::from_text( + format!("Task {}", task.id), + &format_task_detail(task), + width.max(60), + )); +} + +fn format_task_detail(task: &TaskRecord) -> String { + let mut lines = Vec::new(); + lines.push(format!("Task: {}", task.id)); + lines.push(format!("Status: {}", task_status_label(task.status))); + lines.push(format!("Mode: {}", task.mode)); + lines.push(format!("Model: {}", task.model)); + lines.push(format!("Workspace: {}", task.workspace.display())); + if let Some(thread_id) = task.thread_id.as_ref() { + lines.push(format!("Runtime Thread: {thread_id}")); + } + if let Some(turn_id) = task.turn_id.as_ref() { + lines.push(format!("Runtime Turn: {turn_id}")); + } + if task.runtime_event_count > 0 { + lines.push(format!("Runtime Events: {}", task.runtime_event_count)); + } + lines.push(format!("Created: {}", task.created_at)); + if let Some(started_at) = task.started_at { + lines.push(format!("Started: {}", started_at)); + } + if let Some(ended_at) = task.ended_at { + lines.push(format!("Ended: {}", ended_at)); + } + if let Some(duration) = task.duration_ms { + lines.push(format!("Duration: {:.2}s", duration as f64 / 1000.0)); + } + lines.push(String::new()); + lines.push("Prompt:".to_string()); + lines.push(task.prompt.clone()); + + if let Some(summary) = task.result_summary.as_ref() { + lines.push(String::new()); + lines.push("Result Summary:".to_string()); + lines.push(summary.clone()); + } + if let Some(path) = task.result_detail_path.as_ref() { + lines.push(format!("Result Artifact: {}", path.display())); + } + if let Some(error) = task.error.as_ref() { + lines.push(String::new()); + lines.push(format!("Error: {error}")); + } + + lines.push(String::new()); + lines.push("Tool Calls:".to_string()); + if task.tool_calls.is_empty() { + lines.push("- (none)".to_string()); + } else { + for tool in &task.tool_calls { + let status = match tool.status { + crate::task_manager::TaskToolStatus::Running => "running", + crate::task_manager::TaskToolStatus::Success => "success", + crate::task_manager::TaskToolStatus::Failed => "failed", + crate::task_manager::TaskToolStatus::Canceled => "canceled", + }; + let mut line = format!( + "- {} [{}] {}", + tool.name, + status, + tool.output_summary.as_deref().unwrap_or("(no summary)") + ); + if let Some(duration) = tool.duration_ms { + line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0)); + } + lines.push(line); + if let Some(path) = tool.detail_path.as_ref() { + lines.push(format!(" detail: {}", path.display())); + } + if let Some(path) = tool.patch_ref.as_ref() { + lines.push(format!(" patch: {}", path.display())); + } + } + } + + lines.push(String::new()); + lines.push("Timeline:".to_string()); + if task.timeline.is_empty() { + lines.push("- (none)".to_string()); + } else { + for entry in &task.timeline { + lines.push(format!( + "- [{}] {}: {}", + entry.timestamp, entry.kind, entry.summary + )); + if let Some(path) = entry.detail_path.as_ref() { + lines.push(format!(" detail: {}", path.display())); + } + } + } + + lines.join("\n") +} + +#[allow(clippy::too_many_lines)] +fn handle_tool_call_started(app: &mut App, id: &str, name: &str, input: &serde_json::Value) { + let id = id.to_string(); + + // All in-flight tool work for the current turn lives in `app.active_cell` + // until the turn completes. This mirrors Codex's contract: ONE active cell + // mutates in place; finalized history isn't touched until flush. This + // keeps the transcript stable while parallel completions arrive in any + // order. + if app.active_cell.is_none() { + app.active_cell = Some(ActiveCell::new()); + } + + if is_exploring_tool(name) { + let label = exploring_label(name, input); + // ensure_exploring + append_to_exploring keeps all parallel exploring + // starts in a single ExploringCell entry. + let active = app.active_cell.as_mut().expect("active_cell just ensured"); + let entry_idx = active.ensure_exploring(); + let inner = active + .append_to_exploring( + id.clone(), + ExploringEntry { + label, + status: ToolStatus::Running, + }, + ) + .map_or(0, |(_, inner)| inner); + app.exploring_cell = Some(entry_idx); + let virtual_index = app.history.len() + entry_idx; + app.exploring_entries + .insert(id.clone(), (virtual_index, inner)); + register_tool_cell(app, &id, name, input, virtual_index); + app.mark_history_updated(); + return; + } + + // Non-exploring tool: each is its own entry inside the active cell. We + // intentionally do NOT clear `exploring_cell` here — the active cell can + // hold both an exploring aggregate AND independent tool entries + // simultaneously, which is exactly the case CX#7 fixes. + + if is_exec_tool(name) { + let command = exec_command_from_input(input).unwrap_or_else(|| "".to_string()); + let source = exec_source_from_input(input); + let interaction = exec_interaction_summary(name, input); + let mut is_wait = false; + + if let Some((summary, wait)) = interaction.as_ref() { + is_wait = *wait; + if is_wait + && app + .last_exec_wait_command + .as_ref() + .is_some_and(|last| last == &command) + { + app.ignored_tool_calls.insert(id); + return; + } + if is_wait { + app.last_exec_wait_command = Some(command.clone()); + } + + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command, + status: ToolStatus::Running, + output: None, + started_at: Some(Instant::now()), + duration_ms: None, + source, + interaction: Some(summary.clone()), + })), + ); + return; + } + + if exec_is_background(input) + && app + .last_exec_wait_command + .as_ref() + .is_some_and(|last| last == &command) + { + app.ignored_tool_calls.insert(id); + return; + } + if exec_is_background(input) && !is_wait { + app.last_exec_wait_command = Some(command.clone()); + } + + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command, + status: ToolStatus::Running, + output: None, + started_at: Some(Instant::now()), + duration_ms: None, + source, + interaction: None, + })), + ); + return; + } + + if name == "update_plan" { + let (explanation, steps) = parse_plan_input(input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell { + explanation, + steps, + status: ToolStatus::Running, + })), + ); + return; + } + + if name == "apply_patch" { + let (path, summary) = parse_patch_summary(input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell { + path, + summary, + status: ToolStatus::Running, + error: None, + })), + ); + return; + } + + if name == "review" { + let target = review_target_label(input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Review(ReviewCell { + target, + status: ToolStatus::Running, + output: None, + error: None, + })), + ); + return; + } + + if is_mcp_tool(name) { + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Mcp(McpToolCell { + tool: name.to_string(), + status: ToolStatus::Running, + content: None, + is_image: false, + })), + ); + return; + } + + if is_view_image_tool(name) { + if let Some(path) = input.get("path").and_then(|v| v.as_str()) { + let raw_path = PathBuf::from(path); + let display_path = raw_path + .strip_prefix(&app.workspace) + .unwrap_or(&raw_path) + .to_path_buf(); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell { path: display_path })), + ); + } + return; + } + + if is_web_search_tool(name) { + let query = web_search_query(input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell { + query, + status: ToolStatus::Running, + summary: None, + })), + ); + return; + } + + let input_summary = summarize_tool_args(input); + let prompts = extract_fanout_prompts(name, input); + push_active_tool_cell( + app, + &id, + name, + input, + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Running, + input_summary, + output: None, + prompts, + })), + ); +} + +/// Extract per-child prompts from a fan-out tool's input. Currently no +/// top-level tool exposes a prompt list — fan-out lives inside the RLM +/// REPL via `llm_query_batched`. Kept as a stable hook for any future +/// fan-out tool we add. +fn extract_fanout_prompts(_name: &str, _input: &serde_json::Value) -> Option> { + None +} + +/// Push a tool cell as a new entry in `active_cell`, register the tool id, +/// and write a stub detail record so the pager / Ctrl+O can find it. +fn push_active_tool_cell( + app: &mut App, + tool_id: &str, + tool_name: &str, + input: &serde_json::Value, + cell: HistoryCell, +) { + if app.active_cell.is_none() { + app.active_cell = Some(ActiveCell::new()); + } + let active = app.active_cell.as_mut().expect("active_cell just ensured"); + let entry_idx = active.push_tool(tool_id.to_string(), cell); + let virtual_index = app.history.len() + entry_idx; + register_tool_cell(app, tool_id, tool_name, input, virtual_index); + app.mark_history_updated(); +} + +fn register_tool_cell( + app: &mut App, + tool_id: &str, + tool_name: &str, + input: &serde_json::Value, + cell_index: usize, +) { + app.tool_cells.insert(tool_id.to_string(), cell_index); + let record = ToolDetailRecord { + tool_id: tool_id.to_string(), + tool_name: tool_name.to_string(), + input: input.clone(), + output: None, + }; + if cell_index < app.history.len() { + app.tool_details_by_cell.insert(cell_index, record); + } else { + // Active-cell entry: keep the detail record in `active_tool_details` + // until the active cell flushes. `flush_active_cell` migrates these + // records into `tool_details_by_cell` keyed by the eventual real + // cell index. + app.active_tool_details.insert(tool_id.to_string(), record); + } +} + +fn store_tool_detail_output( + app: &mut App, + tool_id: &str, + cell_index: usize, + result: &Result, +) { + let payload = Some(match result { + Ok(tool_result) => tool_result.content.clone(), + Err(err) => err.to_string(), + }); + if cell_index < app.history.len() + && let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index) + { + detail.output = payload.clone(); + } + // Also write to the active table while the entry might still live there; + // some callsites pre-rewrite cell_index but the active_tool_details map is + // the canonical source for in-flight outputs. + if let Some(detail) = app.active_tool_details.get_mut(tool_id) { + detail.output = payload; + } +} + +#[allow(clippy::too_many_lines)] +fn handle_tool_call_complete( + app: &mut App, + id: &str, + name: &str, + result: &Result, +) { + if app.ignored_tool_calls.remove(id) { + return; + } + + // Exploring entries land in the per-tool map regardless of whether they + // live in the active cell or in finalized history; the path is the same. + if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) { + app.tool_cells.remove(id); + store_tool_detail_output(app, id, cell_index, result); + if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) = + app.cell_at_virtual_index_mut(cell_index) + && let Some(entry) = cell.entries.get_mut(entry_index) + { + entry.status = match result.as_ref() { + Ok(tool_result) if tool_result.success => ToolStatus::Success, + Ok(_) | Err(_) => ToolStatus::Failed, + }; + app.mark_history_updated(); + // Mutating the in-flight exploring cell needs an active-cell + // revision bump so the transcript cache invalidates the synthetic + // tail row. + if cell_index >= app.history.len() { + app.active_cell_revision = app.active_cell_revision.wrapping_add(1); + if let Some(active) = app.active_cell.as_mut() { + active.bump_revision(); + } + } + } + return; + } + + // Look up the cell by tool id. If the id isn't registered, that's an + // orphan completion (race condition where the started event was lost or + // a tool result arrived after the active cell was already flushed). Build + // a finalized standalone cell from the result so the user can still see + // the output, but DO NOT touch the active cell. + let Some(cell_index) = app.tool_cells.remove(id) else { + push_orphan_tool_completion(app, id, name, result); + return; + }; + + store_tool_detail_output(app, id, cell_index, result); + let in_active = cell_index >= app.history.len(); + + let status = match result.as_ref() { + Ok(tool_result) => match tool_result.metadata.as_ref() { + Some(meta) + if meta + .get("status") + .and_then(|v| v.as_str()) + .is_some_and(|s| s == "Running") => + { + ToolStatus::Running + } + _ => { + if tool_result.success { + ToolStatus::Success + } else { + ToolStatus::Failed + } + } + }, + Err(_) => ToolStatus::Failed, + }; + + if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) { + match cell { + HistoryCell::Tool(ToolCell::Exec(exec)) => { + exec.status = status; + if let Ok(tool_result) = result.as_ref() { + exec.duration_ms = tool_result + .metadata + .as_ref() + .and_then(|m| m.get("duration_ms")) + .and_then(serde_json::Value::as_u64); + if status != ToolStatus::Running && exec.interaction.is_none() { + exec.output = Some(tool_result.content.clone()); + } + } else if let Err(err) = result.as_ref() + && exec.interaction.is_none() + { + exec.output = Some(err.to_string()); + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => { + plan.status = status; + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::PatchSummary(patch)) => { + patch.status = status; + match result.as_ref() { + Ok(tool_result) => { + if let Ok(json) = + serde_json::from_str::(&tool_result.content) + && let Some(message) = json.get("message").and_then(|v| v.as_str()) + { + patch.summary = message.to_string(); + } + } + Err(err) => { + patch.error = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::Review(review)) => { + review.status = status; + match result.as_ref() { + Ok(tool_result) => { + if tool_result.success { + review.output = Some(ReviewOutput::from_str(&tool_result.content)); + } else { + review.error = Some(tool_result.content.clone()); + } + } + Err(err) => { + review.error = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::Mcp(mcp)) => { + match result.as_ref() { + Ok(tool_result) => { + let summary = summarize_mcp_output(&tool_result.content); + if summary.is_error == Some(true) { + mcp.status = ToolStatus::Failed; + } else { + mcp.status = status; + } + mcp.is_image = summary.is_image; + mcp.content = summary.content; + } + Err(err) => { + mcp.status = status; + mcp.content = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::WebSearch(search)) => { + search.status = status; + match result.as_ref() { + Ok(tool_result) => { + search.summary = Some(summarize_tool_output(&tool_result.content)); + } + Err(err) => { + search.summary = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + HistoryCell::Tool(ToolCell::Generic(generic)) => { + generic.status = status; + match result.as_ref() { + Ok(tool_result) => { + generic.output = Some(summarize_tool_output(&tool_result.content)); + } + Err(err) => { + generic.output = Some(err.to_string()); + } + } + app.mark_history_updated(); + } + _ => {} + } + } + + // If the mutated cell lived inside the active group, bump the active-cell + // revision so the transcript cache re-renders the synthetic tail row. + if in_active { + app.active_cell_revision = app.active_cell_revision.wrapping_add(1); + if let Some(active) = app.active_cell.as_mut() { + active.bump_revision(); + } + } +} + +/// Build a finalized standalone history cell for a tool completion whose +/// start was never registered (orphan). This preserves the contract that +/// every tool result is visible somewhere; the alternative (silently +/// dropping it) hides errors and breaks debuggability. +/// +/// Choice of cell type: we use `GenericToolCell` because we have no input +/// payload to reconstruct a more specific cell. The pager remains usable — +/// `tool_details_by_cell` is populated with the result text. +/// +/// ## Index drift +/// +/// If an active cell is in flight when the orphan arrives, pushing the +/// orphan into `app.history` shifts every active-cell virtual index forward +/// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so +/// later completion lookups still find the right entries. +fn push_orphan_tool_completion( + app: &mut App, + tool_id: &str, + name: &str, + result: &Result, +) { + let status = match result.as_ref() { + Ok(tool_result) => { + if tool_result.success { + ToolStatus::Success + } else { + ToolStatus::Failed + } + } + Err(_) => ToolStatus::Failed, + }; + let output = match result.as_ref() { + Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)), + Err(err) => Some(err.to_string()), + }; + let history_threshold_before_push = app.history.len(); + let active_in_flight = app.active_cell.is_some(); + app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status, + input_summary: None, + output, + prompts: None, + }))); + let cell_index = app.history.len().saturating_sub(1); + app.tool_details_by_cell.insert( + cell_index, + ToolDetailRecord { + tool_id: tool_id.to_string(), + tool_name: name.to_string(), + input: serde_json::Value::Null, + output: match result.as_ref() { + Ok(tool_result) => Some(tool_result.content.clone()), + Err(err) => Some(err.to_string()), + }, + }, + ); + + // Shift active-cell virtual indices forward by 1 to absorb the new + // history cell. Without this, the next completion would address the + // wrong entry. + if active_in_flight { + let threshold = history_threshold_before_push; + for idx in app.tool_cells.values_mut() { + if *idx >= threshold { + *idx = idx.wrapping_add(1); + } + } + for (cell_idx, _) in app.exploring_entries.values_mut() { + if *cell_idx >= threshold { + *cell_idx = cell_idx.wrapping_add(1); + } + } + if let Some(idx) = app.exploring_cell.as_mut() + && *idx >= threshold + { + *idx = idx.wrapping_add(1); + } + } +} + +fn is_exploring_tool(name: &str) -> bool { + matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files") +} + +fn is_exec_tool(name: &str) -> bool { + matches!( + name, + "exec_shell" | "exec_shell_wait" | "exec_shell_interact" | "exec_wait" | "exec_interact" + ) +} + +fn exploring_label(name: &str, input: &serde_json::Value) -> String { + let fallback = format!("{name} tool"); + let obj = input.as_object(); + match name { + "read_file" => obj + .and_then(|o| o.get("path")) + .and_then(|v| v.as_str()) + .map_or(fallback, |path| format!("Reading {path}")), + "list_dir" => obj + .and_then(|o| o.get("path")) + .and_then(|v| v.as_str()) + .map_or("Listing directory".to_string(), |path| { + format!("Listing {path}") + }), + "grep_files" => { + let pattern = obj + .and_then(|o| o.get("pattern")) + .and_then(|v| v.as_str()) + .unwrap_or("pattern"); + format!("Searching for `{pattern}`") + } + "list_files" => "Listing files".to_string(), + _ => fallback, + } +} + +fn is_mcp_tool(name: &str) -> bool { + name.starts_with("mcp_") +} + +fn is_view_image_tool(name: &str) -> bool { + matches!(name, "view_image" | "view_image_file" | "view_image_tool") +} + +fn is_web_search_tool(name: &str) -> bool { + matches!(name, "web_search" | "search_web" | "search" | "web.run") + || name.ends_with("_web_search") +} + +fn web_search_query(input: &serde_json::Value) -> String { + if let Some(searches) = input.get("search_query").and_then(|v| v.as_array()) + && let Some(first) = searches.first() + && let Some(q) = first.get("q").and_then(|v| v.as_str()) + { + return q.to_string(); + } + + input + .get("query") + .or_else(|| input.get("q")) + .or_else(|| input.get("search")) + .and_then(|v| v.as_str()) + .unwrap_or("Web search") + .to_string() +} + +fn review_target_label(input: &serde_json::Value) -> String { + let target = input + .get("target") + .and_then(|v| v.as_str()) + .unwrap_or("review") + .trim(); + let kind = input + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + let staged = input + .get("staged") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let target_lower = target.to_ascii_lowercase(); + + if kind == "diff" + || target_lower == "diff" + || target_lower == "git diff" + || target_lower == "staged" + || target_lower == "cached" + { + if staged || target_lower == "staged" || target_lower == "cached" { + return "git diff --cached".to_string(); + } + return "git diff".to_string(); + } + + target.to_string() +} + +fn parse_plan_input(input: &serde_json::Value) -> (Option, Vec) { + let explanation = input + .get("explanation") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string); + let mut steps = Vec::new(); + if let Some(items) = input.get("plan").and_then(|v| v.as_array()) { + for item in items { + let step = item.get("step").and_then(|v| v.as_str()).unwrap_or(""); + let status = item + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("pending"); + if !step.is_empty() { + steps.push(PlanStep { + step: step.to_string(), + status: status.to_string(), + }); + } + } + } + (explanation, steps) +} + +fn parse_patch_summary(input: &serde_json::Value) -> (String, String) { + if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { + let count = changes.len(); + let path = changes + .first() + .and_then(|c| c.get("path")) + .and_then(|v| v.as_str()) + .map(str::to_string) + .unwrap_or_else(|| "".to_string()); + let label = if count <= 1 { + path + } else { + format!("{count} files") + }; + let summary = format!("Changes: {count} file(s)"); + return (label, summary); + } + + let patch_text = input.get("patch").and_then(|v| v.as_str()).unwrap_or(""); + let paths = extract_patch_paths(patch_text); + let path = input + .get("path") + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + if paths.len() == 1 { + paths.first().cloned() + } else if paths.is_empty() { + None + } else { + Some(format!("{} files", paths.len())) + } + }) + .unwrap_or_else(|| "".to_string()); + + let (adds, removes) = count_patch_changes(patch_text); + let summary = if adds == 0 && removes == 0 { + "Patch applied".to_string() + } else { + format!("Changes: +{adds} / -{removes}") + }; + (path, summary) +} + +fn extract_patch_paths(patch: &str) -> Vec { + let mut paths = Vec::new(); + for line in patch.lines() { + if let Some(rest) = line.strip_prefix("+++ ") { + let raw = rest.trim(); + if raw == "/dev/null" || raw == "dev/null" { + continue; + } + let raw = raw.strip_prefix("b/").unwrap_or(raw); + if !paths.contains(&raw.to_string()) { + paths.push(raw.to_string()); + } + } else if let Some(rest) = line.strip_prefix("diff --git ") { + let parts: Vec<&str> = rest.split_whitespace().collect(); + if let Some(path) = parts.get(1).or_else(|| parts.first()) { + let raw = path.trim(); + let raw = raw + .strip_prefix("b/") + .or_else(|| raw.strip_prefix("a/")) + .unwrap_or(raw); + if !paths.contains(&raw.to_string()) { + paths.push(raw.to_string()); + } + } + } + } + paths +} + +fn maybe_add_patch_preview(app: &mut App, input: &serde_json::Value) { + if let Some(patch) = input.get("patch").and_then(|v| v.as_str()) { + app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { + title: "Patch Preview".to_string(), + diff: patch.to_string(), + }))); + app.mark_history_updated(); + return; + } + + if let Some(changes) = input.get("changes").and_then(|v| v.as_array()) { + let preview = format_changes_preview(changes); + if !preview.trim().is_empty() { + app.add_message(HistoryCell::Tool(ToolCell::DiffPreview(DiffPreviewCell { + title: "Changes Preview".to_string(), + diff: preview, + }))); + app.mark_history_updated(); + } + } +} + +fn format_changes_preview(changes: &[serde_json::Value]) -> String { + let mut out = String::new(); + for change in changes { + let path = change + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let content = change.get("content").and_then(|v| v.as_str()).unwrap_or(""); + + out.push_str(&format!("diff --git a/{path} b/{path}\n")); + out.push_str(&format!("--- a/{path}\n+++ b/{path}\n")); + out.push_str("@@ -0,0 +1,1 @@\n"); + + let mut count = 0usize; + for line in content.lines() { + out.push('+'); + out.push_str(line); + out.push('\n'); + count += 1; + if count >= 20 { + out.push_str("+... (truncated)\n"); + break; + } + } + if content.is_empty() { + out.push_str("+\n"); + } + } + out +} + +fn count_patch_changes(patch: &str) -> (usize, usize) { + let mut adds = 0; + let mut removes = 0; + for line in patch.lines() { + if line.starts_with("+++") || line.starts_with("---") { + continue; + } + if line.starts_with('+') { + adds += 1; + } else if line.starts_with('-') { + removes += 1; + } + } + (adds, removes) +} + +fn exec_command_from_input(input: &serde_json::Value) -> Option { + input + .get("command") + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) +} + +fn exec_source_from_input(input: &serde_json::Value) -> ExecSource { + match input.get("source").and_then(|v| v.as_str()) { + Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User, + _ => ExecSource::Assistant, + } +} + +fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> { + let command = exec_command_from_input(input).unwrap_or_else(|| "".to_string()); + let command_display = format!("\"{command}\""); + let interaction_input = input + .get("input") + .or_else(|| input.get("stdin")) + .or_else(|| input.get("data")) + .and_then(|v| v.as_str()); + + let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait"); + let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact"); + + if is_interact_tool || interaction_input.is_some() { + let preview = interaction_input.map(summarize_interaction_input); + let summary = if let Some(preview) = preview { + format!("Interacted with {command_display}, sent {preview}") + } else { + format!("Interacted with {command_display}") + }; + return Some((summary, false)); + } + + if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) { + return Some((format!("Waited for {command_display}"), true)); + } + + None +} + +fn summarize_interaction_input(input: &str) -> String { + let mut single_line = input.replace('\r', ""); + single_line = single_line.replace('\n', "\\n"); + single_line = single_line.replace('\"', "'"); + let max_len = 80; + if single_line.chars().count() <= max_len { + return format!("\"{single_line}\""); + } + let mut out = String::new(); + for ch in single_line.chars().take(max_len.saturating_sub(3)) { + out.push(ch); + } + out.push_str("..."); + format!("\"{out}\"") +} + +fn exec_is_background(input: &serde_json::Value) -> bool { + input + .get("background") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests; diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 45081e62..89bb8cc5 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -387,6 +387,12 @@ pub fn apply_to_delegate(card: &mut DelegateCard, msg: &MailboxMessage) -> bool // to a sibling fanout card, not this one. return false; } + MailboxMessage::TokenUsage { .. } => { + // Cost accumulation happens in handle_subagent_mailbox (ui.rs) + // before this apply function is called; TokenUsage never reaches + // this arm in practice. + return false; + } } true } @@ -421,6 +427,12 @@ pub fn apply_to_fanout(card: &mut FanoutCard, msg: &MailboxMessage) -> bool { card.upsert_worker(child_id, AgentLifecycle::Pending); true } + MailboxMessage::TokenUsage { .. } => { + // Cost accumulation happens in handle_subagent_mailbox (ui.rs) + // before this apply function is called; TokenUsage never reaches + // this arm in practice. + true + } } } diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 30fbb5ce..97e90460 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -59,15 +59,16 @@ pub struct FooterProps { } /// One frame of the footer's water-spout animation. `col` is the cell index -/// inside the strip, `width` the strip's total width, `frame` the discrete -/// 150 ms tick counter. Returns the glyph that should appear in that cell on +/// inside the strip, `width` the strip's total width, `frame` the raw +/// millisecond counter. Returns the glyph that should appear in that cell on /// that frame. /// /// Visual: two crests sweep across a calm water surface (`─`). The opener -/// `⌒` rises, then a soft `‿` trails behind. Crest A advances every 4 ticks -/// (~600 ms), crest B every 6 ticks (~900 ms) — independent speeds give the -/// criss-cross fountain feel. Every 17 ticks (~2.5 s) the phase of crest B -/// jitters by one column so the pattern never settles into a strict beat. +/// `⌒` rises, then a soft `‿` trails behind. Crest A advances one column +/// every ~600 ms (4 × 150 ms), crest B every ~900 ms (6 × 150 ms) — +/// independent speeds give the criss-cross fountain feel. The positions +/// are computed from `frame / 150.0` (fractional) so crests slide smoothly +/// rather than jumping in discrete 150 ms steps. /// /// All math is pure given (col, width, frame) so unit tests can pin frames. #[must_use] @@ -76,17 +77,22 @@ pub fn footer_working_strip_glyph_at(col: usize, width: usize, frame: u64) -> ch return ' '; } + // Number of 150 ms ticks since epoch — fractional so crests move + // continuously rather than teleporting every 4-6 ticks. + let frame_f = frame as f64 / 150.0; + // Crest is two glyphs wide: the leading `⌒` followed by a trailing `‿`. const CREST_SPAN: i64 = 2; // Cycle wide enough that each crest enters and exits cleanly. let cycle = (width as i64).max(CREST_SPAN) + CREST_SPAN * 2; - let frame_i = frame as i64; - // Crest A advances one column every 4 ticks; B every 6. - let pos_a = frame_i.div_euclid(4).rem_euclid(cycle) - CREST_SPAN; - // Phase jitter: every 17 ticks, nudge B by one column so the two crests - // never lock into a fixed offset. - let jitter = frame_i.div_euclid(17).rem_euclid(3); - let pos_b = (frame_i.div_euclid(6) + jitter + (cycle / 3) + 5).rem_euclid(cycle) - CREST_SPAN; + // Crest A advances one column every ~300 ms (2 × 150 ms ticks). + let pos_a = (frame_f / 2.0).round() as i64 % cycle - CREST_SPAN; + // Phase jitter: every ~2.5 s (17 ticks), nudge B by one column so the + // two crests never lock into a fixed offset. + let jitter = (frame_f / 17.0).round() as i64 % 3; + // Crest B advances one column every ~450 ms (3 × 150 ms ticks). + let pos_b = + ((frame_f / 3.0).round() as i64 + jitter + (cycle / 3) + 5).rem_euclid(cycle) - CREST_SPAN; crest_glyph_for(col as i64, pos_a) .or_else(|| crest_glyph_for(col as i64, pos_b)) @@ -687,16 +693,16 @@ mod tests { #[test] fn working_strip_glyph_is_deterministic_per_frame() { - // Same (col, width, frame) → same glyph. Stepping by one full - // crest-A tick (4 ticks ≈ 600 ms) is the minimum guaranteed - // animation step. - let a = super::footer_working_strip_string(40, 1); - let b = super::footer_working_strip_string(40, 1); + // Same (col, width, frame) → same glyph. Frames are now raw + // milliseconds; 150 ms apart represents one tick. + let a = super::footer_working_strip_string(40, 150); + let b = super::footer_working_strip_string(40, 150); assert_eq!(a, b, "deterministic given the same frame"); - let c = super::footer_working_strip_string(40, 5); + // 750 ms → 5 ticks, crest A advances every 2 ticks → ≥2 steps. + let c = super::footer_working_strip_string(40, 750); assert_ne!( a, c, - "advancing one full crest-A step must change the strip", + "advancing 4 ticks must change the strip", ); } @@ -713,7 +719,7 @@ mod tests { FooterWidget::new(props.clone()).render(area, &mut buf); let idle: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect(); - props.working_strip_frame = Some(13); + props.working_strip_frame = Some(600); let mut buf2 = ratatui::buffer::Buffer::empty(area); FooterWidget::new(props).render(area, &mut buf2); let active: String = (0..area.width).map(|x| buf2[(x, 0)].symbol()).collect(); @@ -732,12 +738,11 @@ mod tests { #[test] fn working_strip_advances_position_within_full_crest_step() { - // Crest A advances one column every 4 ticks; B every 6. Stepping by - // 12 ticks guarantees both have moved at least one column, - // independent of the jitter cadence (17). + // Crest A advances every 2 ticks (300 ms), B every 3 (450 ms). + // 900 ms (6 ticks) guarantees crest A has advanced at least 3 columns. let width = 60; let f0 = super::footer_working_strip_string(width, 0); - let f12 = super::footer_working_strip_string(width, 12); + let f900 = super::footer_working_strip_string(width, 900); // Collect the columns that hold a crest opener `⌒` in each frame. let openers = |s: &str| -> Vec { s.chars() @@ -747,20 +752,20 @@ mod tests { }; assert_ne!( openers(&f0), - openers(&f12), - "crest opener columns must shift across a 12-tick window", + openers(&f900), + "crest opener columns must shift across a 900ms window", ); } #[test] fn working_strip_renders_paired_crest_glyphs() { // The `⌒‿` pair is the visual centrepiece — a soft rise followed by - // a gentle dip. Sweep enough ticks that a crest is guaranteed to - // land fully inside a 60-cell strip at some point. + // a gentle dip. Sweep enough time (in ms) that a crest is guaranteed + // to land fully inside a 60-cell strip at some point. let width = 60; let mut saw_pair = false; - for frame in 0..120 { - let s = super::footer_working_strip_string(width, frame); + for frame_ms in (0..24_000).step_by(150) { + let s = super::footer_working_strip_string(width, frame_ms); if s.contains("\u{2312}\u{203F}") { saw_pair = true; break; @@ -768,7 +773,7 @@ mod tests { } assert!( saw_pair, - "expected `⌒‿` pair somewhere in the first 120 ticks", + "expected `⌒‿` pair somewhere in the first 24s of animation", ); }