chenbhao commited on
Commit
bfdb3c0
·
1 Parent(s): 38c9372

fix: desktop provider kepp TUI

Browse files
desktop/src-tauri/Cargo.lock CHANGED
@@ -485,6 +485,7 @@ name = "claude-code-desktop"
485
  version = "0.2.9"
486
  dependencies = [
487
  "anyhow",
 
488
  "portable-pty",
489
  "reqwest",
490
  "serde",
@@ -784,13 +785,34 @@ dependencies = [
784
  "crypto-common",
785
  ]
786
 
 
 
 
 
 
 
 
 
 
787
  [[package]]
788
  name = "dirs"
789
  version = "6.0.0"
790
  source = "registry+https://github.com/rust-lang/crates.io-index"
791
  checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
792
  dependencies = [
793
- "dirs-sys",
 
 
 
 
 
 
 
 
 
 
 
 
794
  ]
795
 
796
  [[package]]
@@ -801,7 +823,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
801
  dependencies = [
802
  "libc",
803
  "option-ext",
804
- "redox_users",
805
  "windows-sys 0.61.2",
806
  ]
807
 
@@ -3199,6 +3221,17 @@ dependencies = [
3199
  "bitflags 2.11.1",
3200
  ]
3201
 
 
 
 
 
 
 
 
 
 
 
 
3202
  [[package]]
3203
  name = "redox_users"
3204
  version = "0.5.2"
@@ -4175,7 +4208,7 @@ dependencies = [
4175
  "anyhow",
4176
  "bytes",
4177
  "cookie",
4178
- "dirs",
4179
  "dunce",
4180
  "embed_plist",
4181
  "getrandom 0.3.4",
@@ -4225,7 +4258,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
4225
  dependencies = [
4226
  "anyhow",
4227
  "cargo_toml",
4228
- "dirs",
4229
  "glob",
4230
  "heck 0.5.0",
4231
  "json-patch",
@@ -4410,7 +4443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4410
  checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
4411
  dependencies = [
4412
  "base64 0.22.1",
4413
- "dirs",
4414
  "flate2",
4415
  "futures-util",
4416
  "http",
@@ -4920,7 +4953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4920
  checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
4921
  dependencies = [
4922
  "crossbeam-channel",
4923
- "dirs",
4924
  "libappindicator",
4925
  "muda",
4926
  "objc2",
@@ -5577,6 +5610,15 @@ dependencies = [
5577
  "windows-targets 0.42.2",
5578
  ]
5579
 
 
 
 
 
 
 
 
 
 
5580
  [[package]]
5581
  name = "windows-sys"
5582
  version = "0.52.0"
@@ -5628,6 +5670,21 @@ dependencies = [
5628
  "windows_x86_64_msvc 0.42.2",
5629
  ]
5630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5631
  [[package]]
5632
  name = "windows-targets"
5633
  version = "0.52.6"
@@ -5685,6 +5742,12 @@ version = "0.42.2"
5685
  source = "registry+https://github.com/rust-lang/crates.io-index"
5686
  checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
5687
 
 
 
 
 
 
 
5688
  [[package]]
5689
  name = "windows_aarch64_gnullvm"
5690
  version = "0.52.6"
@@ -5703,6 +5766,12 @@ version = "0.42.2"
5703
  source = "registry+https://github.com/rust-lang/crates.io-index"
5704
  checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
5705
 
 
 
 
 
 
 
5706
  [[package]]
5707
  name = "windows_aarch64_msvc"
5708
  version = "0.52.6"
@@ -5721,6 +5790,12 @@ version = "0.42.2"
5721
  source = "registry+https://github.com/rust-lang/crates.io-index"
5722
  checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
5723
 
 
 
 
 
 
 
5724
  [[package]]
5725
  name = "windows_i686_gnu"
5726
  version = "0.52.6"
@@ -5751,6 +5826,12 @@ version = "0.42.2"
5751
  source = "registry+https://github.com/rust-lang/crates.io-index"
5752
  checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
5753
 
 
 
 
 
 
 
5754
  [[package]]
5755
  name = "windows_i686_msvc"
5756
  version = "0.52.6"
@@ -5769,6 +5850,12 @@ version = "0.42.2"
5769
  source = "registry+https://github.com/rust-lang/crates.io-index"
5770
  checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
5771
 
 
 
 
 
 
 
5772
  [[package]]
5773
  name = "windows_x86_64_gnu"
5774
  version = "0.52.6"
@@ -5787,6 +5874,12 @@ version = "0.42.2"
5787
  source = "registry+https://github.com/rust-lang/crates.io-index"
5788
  checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
5789
 
 
 
 
 
 
 
5790
  [[package]]
5791
  name = "windows_x86_64_gnullvm"
5792
  version = "0.52.6"
@@ -5805,6 +5898,12 @@ version = "0.42.2"
5805
  source = "registry+https://github.com/rust-lang/crates.io-index"
5806
  checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
5807
 
 
 
 
 
 
 
5808
  [[package]]
5809
  name = "windows_x86_64_msvc"
5810
  version = "0.52.6"
@@ -5970,7 +6069,7 @@ dependencies = [
5970
  "block2",
5971
  "cookie",
5972
  "crossbeam-channel",
5973
- "dirs",
5974
  "dom_query",
5975
  "dpi",
5976
  "dunce",
 
485
  version = "0.2.9"
486
  dependencies = [
487
  "anyhow",
488
+ "dirs 5.0.1",
489
  "portable-pty",
490
  "reqwest",
491
  "serde",
 
785
  "crypto-common",
786
  ]
787
 
788
+ [[package]]
789
+ name = "dirs"
790
+ version = "5.0.1"
791
+ source = "registry+https://github.com/rust-lang/crates.io-index"
792
+ checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
793
+ dependencies = [
794
+ "dirs-sys 0.4.1",
795
+ ]
796
+
797
  [[package]]
798
  name = "dirs"
799
  version = "6.0.0"
800
  source = "registry+https://github.com/rust-lang/crates.io-index"
801
  checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
802
  dependencies = [
803
+ "dirs-sys 0.5.0",
804
+ ]
805
+
806
+ [[package]]
807
+ name = "dirs-sys"
808
+ version = "0.4.1"
809
+ source = "registry+https://github.com/rust-lang/crates.io-index"
810
+ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
811
+ dependencies = [
812
+ "libc",
813
+ "option-ext",
814
+ "redox_users 0.4.6",
815
+ "windows-sys 0.48.0",
816
  ]
817
 
818
  [[package]]
 
823
  dependencies = [
824
  "libc",
825
  "option-ext",
826
+ "redox_users 0.5.2",
827
  "windows-sys 0.61.2",
828
  ]
829
 
 
3221
  "bitflags 2.11.1",
3222
  ]
3223
 
3224
+ [[package]]
3225
+ name = "redox_users"
3226
+ version = "0.4.6"
3227
+ source = "registry+https://github.com/rust-lang/crates.io-index"
3228
+ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
3229
+ dependencies = [
3230
+ "getrandom 0.2.17",
3231
+ "libredox",
3232
+ "thiserror 1.0.69",
3233
+ ]
3234
+
3235
  [[package]]
3236
  name = "redox_users"
3237
  version = "0.5.2"
 
4208
  "anyhow",
4209
  "bytes",
4210
  "cookie",
4211
+ "dirs 6.0.0",
4212
  "dunce",
4213
  "embed_plist",
4214
  "getrandom 0.3.4",
 
4258
  dependencies = [
4259
  "anyhow",
4260
  "cargo_toml",
4261
+ "dirs 6.0.0",
4262
  "glob",
4263
  "heck 0.5.0",
4264
  "json-patch",
 
4443
  checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
4444
  dependencies = [
4445
  "base64 0.22.1",
4446
+ "dirs 6.0.0",
4447
  "flate2",
4448
  "futures-util",
4449
  "http",
 
4953
  checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
4954
  dependencies = [
4955
  "crossbeam-channel",
4956
+ "dirs 6.0.0",
4957
  "libappindicator",
4958
  "muda",
4959
  "objc2",
 
5610
  "windows-targets 0.42.2",
5611
  ]
5612
 
5613
+ [[package]]
5614
+ name = "windows-sys"
5615
+ version = "0.48.0"
5616
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5617
+ checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
5618
+ dependencies = [
5619
+ "windows-targets 0.48.5",
5620
+ ]
5621
+
5622
  [[package]]
5623
  name = "windows-sys"
5624
  version = "0.52.0"
 
5670
  "windows_x86_64_msvc 0.42.2",
5671
  ]
5672
 
5673
+ [[package]]
5674
+ name = "windows-targets"
5675
+ version = "0.48.5"
5676
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5677
+ checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
5678
+ dependencies = [
5679
+ "windows_aarch64_gnullvm 0.48.5",
5680
+ "windows_aarch64_msvc 0.48.5",
5681
+ "windows_i686_gnu 0.48.5",
5682
+ "windows_i686_msvc 0.48.5",
5683
+ "windows_x86_64_gnu 0.48.5",
5684
+ "windows_x86_64_gnullvm 0.48.5",
5685
+ "windows_x86_64_msvc 0.48.5",
5686
+ ]
5687
+
5688
  [[package]]
5689
  name = "windows-targets"
5690
  version = "0.52.6"
 
5742
  source = "registry+https://github.com/rust-lang/crates.io-index"
5743
  checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
5744
 
5745
+ [[package]]
5746
+ name = "windows_aarch64_gnullvm"
5747
+ version = "0.48.5"
5748
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5749
+ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
5750
+
5751
  [[package]]
5752
  name = "windows_aarch64_gnullvm"
5753
  version = "0.52.6"
 
5766
  source = "registry+https://github.com/rust-lang/crates.io-index"
5767
  checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
5768
 
5769
+ [[package]]
5770
+ name = "windows_aarch64_msvc"
5771
+ version = "0.48.5"
5772
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5773
+ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
5774
+
5775
  [[package]]
5776
  name = "windows_aarch64_msvc"
5777
  version = "0.52.6"
 
5790
  source = "registry+https://github.com/rust-lang/crates.io-index"
5791
  checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
5792
 
5793
+ [[package]]
5794
+ name = "windows_i686_gnu"
5795
+ version = "0.48.5"
5796
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5797
+ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
5798
+
5799
  [[package]]
5800
  name = "windows_i686_gnu"
5801
  version = "0.52.6"
 
5826
  source = "registry+https://github.com/rust-lang/crates.io-index"
5827
  checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
5828
 
5829
+ [[package]]
5830
+ name = "windows_i686_msvc"
5831
+ version = "0.48.5"
5832
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5833
+ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
5834
+
5835
  [[package]]
5836
  name = "windows_i686_msvc"
5837
  version = "0.52.6"
 
5850
  source = "registry+https://github.com/rust-lang/crates.io-index"
5851
  checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
5852
 
5853
+ [[package]]
5854
+ name = "windows_x86_64_gnu"
5855
+ version = "0.48.5"
5856
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5857
+ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
5858
+
5859
  [[package]]
5860
  name = "windows_x86_64_gnu"
5861
  version = "0.52.6"
 
5874
  source = "registry+https://github.com/rust-lang/crates.io-index"
5875
  checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
5876
 
5877
+ [[package]]
5878
+ name = "windows_x86_64_gnullvm"
5879
+ version = "0.48.5"
5880
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5881
+ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
5882
+
5883
  [[package]]
5884
  name = "windows_x86_64_gnullvm"
5885
  version = "0.52.6"
 
5898
  source = "registry+https://github.com/rust-lang/crates.io-index"
5899
  checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
5900
 
5901
+ [[package]]
5902
+ name = "windows_x86_64_msvc"
5903
+ version = "0.48.5"
5904
+ source = "registry+https://github.com/rust-lang/crates.io-index"
5905
+ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
5906
+
5907
  [[package]]
5908
  name = "windows_x86_64_msvc"
5909
  version = "0.52.6"
 
6069
  "block2",
6070
  "cookie",
6071
  "crossbeam-channel",
6072
+ "dirs 6.0.0",
6073
  "dom_query",
6074
  "dpi",
6075
  "dunce",
desktop/src-tauri/Cargo.toml CHANGED
@@ -25,3 +25,4 @@ portable-pty = "0.9.0"
25
  tauri-plugin-notification = "2"
26
  tauri-plugin-single-instance = "2"
27
  reqwest = { version = "0.13", default-features = false, features = ["system-proxy"] }
 
 
25
  tauri-plugin-notification = "2"
26
  tauri-plugin-single-instance = "2"
27
  reqwest = { version = "0.13", default-features = false, features = ["system-proxy"] }
28
+ dirs = "5"
desktop/src-tauri/src/lib.rs CHANGED
@@ -1151,6 +1151,78 @@ fn set_app_zoom(window: tauri::WebviewWindow, zoom_factor: f64) -> Result<(), St
1151
  .map_err(|err| format!("set app zoom: {err}"))
1152
  }
1153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1154
  #[cfg(target_os = "windows")]
1155
  fn open_windows_notification_settings_impl() -> Result<bool, String> {
1156
  StdCommand::new("explorer.exe")
@@ -2129,7 +2201,9 @@ pub fn run() {
2129
  get_app_mode,
2130
  set_app_mode,
2131
  detect_portable_dir,
2132
- set_app_zoom
 
 
2133
  ]);
2134
 
2135
  // macOS: native menu bar (traffic-light overlay style)
 
1151
  .map_err(|err| format!("set app zoom: {err}"))
1152
  }
1153
 
1154
+ /// Get the path to ~/.claude.json (mirrors TUI's getGlobalClaudeFile)
1155
+ fn get_global_claude_file_path() -> std::path::PathBuf {
1156
+ // Same logic as TUI's getGlobalClaudeFile(): follow CLAUDE_CONFIG_DIR, then homedir
1157
+ if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
1158
+ std::path::PathBuf::from(config_dir).join(".claude.json")
1159
+ } else {
1160
+ dirs::home_dir()
1161
+ .map(|h| h.join(".claude.json"))
1162
+ .unwrap_or_else(|| std::path::PathBuf::from(".claude.json"))
1163
+ }
1164
+ }
1165
+
1166
+ /// Read the global Claude config file (~/.claude.json).
1167
+ /// Returns the parsed JSON or null if the file doesn't exist.
1168
+ #[tauri::command]
1169
+ fn get_claude_config() -> Result<serde_json::Value, String> {
1170
+ let path = get_global_claude_file_path();
1171
+ match fs::read_to_string(&path) {
1172
+ Ok(content) => serde_json::from_str(&content)
1173
+ .map_err(|e| format!("parse {}: {e}", path.display())),
1174
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Value::Null),
1175
+ Err(e) => Err(format!("read {}: {e}", path.display())),
1176
+ }
1177
+ }
1178
+
1179
+ /// Write the global Claude config file (~/.claude.json).
1180
+ /// The content is merged with the existing file (deep merge).
1181
+ #[tauri::command]
1182
+ fn save_claude_config(patch: serde_json::Value) -> Result<(), String> {
1183
+ let path = get_global_claude_file_path();
1184
+ // Ensure parent directory exists
1185
+ if let Some(parent) = path.parent() {
1186
+ fs::create_dir_all(parent)
1187
+ .map_err(|e| format!("create config dir {}: {e}", parent.display()))?;
1188
+ }
1189
+ // Read existing content
1190
+ let existing: serde_json::Value = if path.exists() {
1191
+ let content = fs::read_to_string(&path)
1192
+ .map_err(|e| format!("read {}: {e}", path.display()))?;
1193
+ serde_json::from_str(&content)
1194
+ .unwrap_or(serde_json::Value::Object(serde_json::Map::new()))
1195
+ } else {
1196
+ serde_json::Value::Object(serde_json::Map::new())
1197
+ };
1198
+ // Deep merge: patch overrides existing
1199
+ let merged = deep_merge_json(&existing, &patch);
1200
+ let json = serde_json::to_string_pretty(&merged)
1201
+ .map_err(|e| format!("serialize config: {e}"))?;
1202
+ fs::write(&path, json)
1203
+ .map_err(|e| format!("write {}: {e}", path.display()))?;
1204
+ Ok(())
1205
+ }
1206
+
1207
+ /// Deep-merge two JSON objects. Patch values override existing ones.
1208
+ /// Arrays are replaced, not concatenated.
1209
+ fn deep_merge_json(base: &serde_json::Value, patch: &serde_json::Value) -> serde_json::Value {
1210
+ match (base, patch) {
1211
+ (serde_json::Value::Object(base_map), serde_json::Value::Object(patch_map)) => {
1212
+ let mut result = base_map.clone();
1213
+ for (key, patch_val) in patch_map {
1214
+ let merged = match result.get(key) {
1215
+ Some(existing_val) => deep_merge_json(existing_val, patch_val),
1216
+ None => patch_val.clone(),
1217
+ };
1218
+ result.insert(key.clone(), merged);
1219
+ }
1220
+ serde_json::Value::Object(result)
1221
+ }
1222
+ _ => patch.clone(),
1223
+ }
1224
+ }
1225
+
1226
  #[cfg(target_os = "windows")]
1227
  fn open_windows_notification_settings_impl() -> Result<bool, String> {
1228
  StdCommand::new("explorer.exe")
 
2201
  get_app_mode,
2202
  set_app_mode,
2203
  detect_portable_dir,
2204
+ set_app_zoom,
2205
+ get_claude_config,
2206
+ save_claude_config
2207
  ]);
2208
 
2209
  // macOS: native menu bar (traffic-light overlay style)
desktop/src-tauri/tauri.conf.json CHANGED
@@ -24,7 +24,7 @@
24
  ],
25
  "security": {
26
  "dangerousDisableAssetCspModification": ["style-src"],
27
- "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost http://127.0.0.1:* http://localhost:*; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
28
  }
29
  },
30
  "plugins": {
 
24
  ],
25
  "security": {
26
  "dangerousDisableAssetCspModification": ["style-src"],
27
+ "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost http://127.0.0.1:* http://localhost:*; font-src 'self' data:; connect-src 'self' https: ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
28
  }
29
  },
30
  "plugins": {
desktop/src/api/config.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TUI-style configuration reading layer for desktop.
3
+ *
4
+ * Mirrors TUI's src/utils/config.ts and src/utils/env.ts reading patterns,
5
+ * using Rust Tauri commands to read ~/.claude.json directly instead of going
6
+ * through the cc-haha sidecar HTTP API.
7
+ *
8
+ * This allows the desktop app to use the same configuration files as the TUI,
9
+ * making cc-haha's /api/cli-auth endpoint unnecessary for config reads.
10
+ */
11
+
12
+ import { invoke } from '@tauri-apps/api/core'
13
+
14
+ // Re-export types that mirror TUI's GlobalConfig shape
15
+ export type TuiGlobalConfig = {
16
+ // Auth provider
17
+ authProvider?: 'anthropic' | 'openrouter' | 'openai' | 'local' | 'opencode' | 'nvidia' | null
18
+
19
+ // API Keys
20
+ primaryApiKey?: string
21
+ openAiApiKey?: string
22
+ openAiAccessToken?: string
23
+ openRouterApiKey?: string
24
+ nvidiaApiKey?: string
25
+ openCodeApiKey?: string
26
+ openCodeModelName?: string
27
+ localBaseUrl?: string
28
+ localModelName?: string
29
+
30
+ // OAuth
31
+ oauthAccount?: {
32
+ emailAddress?: string
33
+ organizationName?: string
34
+ uuid?: string
35
+ }
36
+
37
+ // Model selection
38
+ model?: string
39
+ effortLevel?: string
40
+
41
+ // Other fields we don't actively use but preserve
42
+ [key: string]: unknown
43
+ }
44
+
45
+ let configCache: TuiGlobalConfig | null = null
46
+ let configCacheTime = 0
47
+ const CONFIG_CACHE_TTL = 2000 // 2 seconds — balance freshness vs perf
48
+
49
+ /**
50
+ * Get the global Claude config (~/.claude.json) from the Rust layer.
51
+ * Results are cached for CONFIG_CACHE_TTL ms to avoid excessive Rust IPC calls.
52
+ */
53
+ export async function getTuiConfig(): Promise<TuiGlobalConfig> {
54
+ const now = Date.now()
55
+ if (configCache !== null && now - configCacheTime < CONFIG_CACHE_TTL) {
56
+ return configCache
57
+ }
58
+
59
+ try {
60
+ const result = await invoke<unknown>('get_claude_config')
61
+ if (result === null) {
62
+ configCache = {}
63
+ } else {
64
+ configCache = result as TuiGlobalConfig
65
+ }
66
+ configCacheTime = now
67
+ return configCache!
68
+ } catch (err) {
69
+ console.error('[config] failed to read ~/.claude.json:', err)
70
+ return {}
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Save a partial patch to ~/.claude.json (deep-merged with existing content).
76
+ */
77
+ export async function saveTuiConfigPatch(patch: Partial<TuiGlobalConfig>): Promise<void> {
78
+ await invoke('save_claude_config', { patch })
79
+ // Invalidate cache so next read picks up the change
80
+ configCache = null
81
+ configCacheTime = 0
82
+ }
83
+
84
+ /**
85
+ * Clear the in-memory config cache.
86
+ * Call this after making config changes to force a fresh read.
87
+ */
88
+ export function clearConfigCache(): void {
89
+ configCache = null
90
+ configCacheTime = 0
91
+ }
desktop/src/api/providerModels.ts ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Direct provider model fetcher.
3
+ *
4
+ * Fetches available models directly from provider APIs, mirroring the logic
5
+ * in TUI's openRouterModels.ts / nvidiaClient.ts / opencodeClient.ts.
6
+ *
7
+ * This bypasses the cc-haha sidecar's /api/models endpoint entirely,
8
+ * reusing TUI's model acquisition approach in the desktop WebView.
9
+ */
10
+
11
+ import { getTuiConfig } from './config'
12
+ import type { ModelInfo } from '../types/settings'
13
+
14
+ // ─── Type ────────────────────────────────────────────────────────────────────
15
+
16
+ type FetchResult = {
17
+ models: ModelInfo[]
18
+ provider: { id: string; name: string } | null
19
+ }
20
+
21
+ // ─── Cache ───────────────────────────────────────────────────────────────────
22
+
23
+ let modelCache: { key: string; models: ModelInfo[] } | null = null
24
+ let cacheTime = 0
25
+ const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
26
+
27
+ export function clearProviderModelCache(): void {
28
+ modelCache = null
29
+ cacheTime = 0
30
+ }
31
+
32
+ // ─── Main entry ──────────────────────────────────────────────────────────────
33
+
34
+ export async function fetchProviderModels(): Promise<FetchResult> {
35
+ const config = await getTuiConfig()
36
+ const authProvider = (config.authProvider as string | undefined) || null
37
+
38
+ const cacheKey = authProvider ?? '__default__'
39
+ if (modelCache && modelCache.key === cacheKey && Date.now() - cacheTime < CACHE_TTL) {
40
+ return { models: modelCache.models, provider: null }
41
+ }
42
+
43
+ let models: ModelInfo[] = []
44
+ let providerInfo: { id: string; name: string } | null = null
45
+
46
+ switch (authProvider) {
47
+ case 'openrouter': {
48
+ const apiKey = config.openRouterApiKey as string | undefined
49
+ models = await fetchOpenRouterModels(apiKey)
50
+ providerInfo = models.length > 0
51
+ ? { id: 'cli-openrouter', name: 'OpenRouter' }
52
+ : null
53
+ break
54
+ }
55
+
56
+ case 'nvidia': {
57
+ const apiKey = config.nvidiaApiKey as string | undefined
58
+ const baseUrl = config.nvidiaBaseUrl as string | undefined
59
+ models = await fetchNvidiaModels(apiKey, baseUrl || 'https://integrate.api.nvidia.com/v1')
60
+ providerInfo = models.length > 0
61
+ ? { id: 'cli-nvidia', name: 'NVIDIA' }
62
+ : null
63
+ break
64
+ }
65
+
66
+ case 'opencode': {
67
+ models = await fetchOpencodeModels()
68
+ providerInfo = models.length > 0
69
+ ? { id: 'cli-opencode', name: 'OpenCode Zen' }
70
+ : null
71
+ break
72
+ }
73
+
74
+ case 'openai':
75
+ // OpenAI models are best fetched via the official provider API.
76
+ // For now, the static catalog from modelCatalog.ts is used.
77
+ break
78
+
79
+ case 'local': {
80
+ const modelName = config.localModelName as string | undefined
81
+ if (modelName) {
82
+ models = [{ id: modelName, name: modelName, description: 'Local model', context: '' }]
83
+ }
84
+ providerInfo = { id: 'cli-local', name: 'Local' }
85
+ break
86
+ }
87
+
88
+ default:
89
+ // firstParty / anthropic — no external fetch, use static defaults
90
+ break
91
+ }
92
+
93
+ modelCache = { key: cacheKey, models }
94
+ cacheTime = Date.now()
95
+
96
+ return { models, provider: providerInfo }
97
+ }
98
+
99
+ // ─── OpenRouter ──────────────────────────────────────────────────────────────
100
+
101
+ let orCache: ModelInfo[] | null = null
102
+ let orCacheTime = 0
103
+ const OR_CACHE_TTL = 5 * 60 * 1000
104
+
105
+ async function fetchOpenRouterModels(apiKey?: string | null): Promise<ModelInfo[]> {
106
+ if (orCache && Date.now() - orCacheTime < OR_CACHE_TTL) {
107
+ return orCache
108
+ }
109
+
110
+ if (!apiKey) {
111
+ orCache = []
112
+ orCacheTime = Date.now()
113
+ return []
114
+ }
115
+
116
+ try {
117
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
118
+ headers: { Authorization: `Bearer ${apiKey}` },
119
+ signal: AbortSignal.timeout(20_000),
120
+ })
121
+ if (!res.ok) {
122
+ orCache = []
123
+ return []
124
+ }
125
+
126
+ const data = (await res.json()) as { data?: Array<{ id: string; name: string; description?: string; context_length?: number }> }
127
+ const models: ModelInfo[] = (data.data || [])
128
+ .filter((m) => {
129
+ const id = m.id.toLowerCase()
130
+ return id.includes('/') && !id.startsWith('router') && !id.startsWith('free') && !id.startsWith('aggregat')
131
+ })
132
+ .map((m) => ({
133
+ id: m.id,
134
+ name: m.name || m.id,
135
+ description: (m.description || '').length > 100 ? m.description!.slice(0, 97) + '...' : (m.description || ''),
136
+ context: String(m.context_length || ''),
137
+ }))
138
+
139
+ orCache = models
140
+ orCacheTime = Date.now()
141
+ return models
142
+ } catch {
143
+ orCache = orCache || []
144
+ return orCache
145
+ }
146
+ }
147
+
148
+ // ─── NVIDIA NIM ──────────────────────────────────────────────────────────────
149
+
150
+ let nvCache: ModelInfo[] | null = null
151
+ let nvCacheTime = 0
152
+
153
+ async function fetchNvidiaModels(apiKey?: string | null, baseUrl?: string): Promise<ModelInfo[]> {
154
+ if (nvCache && Date.now() - nvCacheTime < OR_CACHE_TTL) {
155
+ return nvCache
156
+ }
157
+
158
+ if (!apiKey) {
159
+ nvCache = []
160
+ nvCacheTime = Date.now()
161
+ return []
162
+ }
163
+
164
+ const normalized = (baseUrl || 'https://integrate.api.nvidia.com/v1').replace(/\/$/, '')
165
+ const modelsUrl = normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`
166
+
167
+ try {
168
+ const res = await fetch(modelsUrl, {
169
+ headers: { Authorization: `Bearer ${apiKey}` },
170
+ signal: AbortSignal.timeout(20_000),
171
+ })
172
+ if (!res.ok) {
173
+ nvCache = []
174
+ return []
175
+ }
176
+
177
+ const json = (await res.json()) as { data?: Array<{ id: string }> }
178
+ if (json.data && Array.isArray(json.data)) {
179
+ const models: ModelInfo[] = json.data.map((m) => ({
180
+ id: m.id,
181
+ name: m.id,
182
+ description: 'NVIDIA NIM model',
183
+ context: '',
184
+ }))
185
+ nvCache = models
186
+ nvCacheTime = Date.now()
187
+ return models
188
+ }
189
+
190
+ nvCache = []
191
+ return []
192
+ } catch {
193
+ nvCache = nvCache || []
194
+ return nvCache
195
+ }
196
+ }
197
+
198
+ // ─── OpenCode Zen ────────────────────────────────────────────────────────────
199
+
200
+ export async function fetchOpencodeModels(): Promise<ModelInfo[]> {
201
+ try {
202
+ const res = await fetch('https://models.dev/api.json', {
203
+ signal: AbortSignal.timeout(15_000),
204
+ })
205
+ if (!res.ok) return []
206
+
207
+ const data = (await res.json()) as {
208
+ opencode?: { models?: Record<string, { name?: string; status?: string; cost?: { input?: number; output?: number } }> }
209
+ }
210
+ const opencodeModels = data?.opencode?.models || {}
211
+ const models: ModelInfo[] = []
212
+
213
+ for (const [modelId, cfg] of Object.entries(opencodeModels)) {
214
+ if (cfg.status === 'deprecated') continue
215
+ const isFree = cfg.cost?.input === 0 && cfg.cost?.output === 0
216
+ models.push({
217
+ id: modelId,
218
+ name: cfg.name || modelId,
219
+ description: isFree ? 'Free model' : 'Paid model',
220
+ context: '',
221
+ })
222
+ }
223
+
224
+ return models
225
+ } catch {
226
+ return []
227
+ }
228
+ }
desktop/src/lib/modelOptions.ts ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Desktop model options utility.
3
+ *
4
+ * Mirrors key logic from TUI's src/utils/model/modelOptions.ts
5
+ * but adapted for the desktop React context.
6
+ *
7
+ * Core value: provider-tier-aware model filtering (Max gets Opus,
8
+ * Pro gets Sonnet, etc.) and environment-variable overrides that
9
+ * TUI supports but the cc-haha /api/models endpoint doesn't expose.
10
+ */
11
+
12
+ import type { ModelInfo } from '../types/settings'
13
+
14
+ export type ModelOption = {
15
+ value: string | null // null = default
16
+ label: string
17
+ description: string
18
+ descriptionForModel?: string
19
+ }
20
+
21
+ /** Determine the effective auth provider from ~/.claude.json config. */
22
+ export async function getConfigAuthProvider(): Promise<string | null> {
23
+ const { invoke } = await import('@tauri-apps/api/core')
24
+ try {
25
+ const config = await invoke<Record<string, unknown>>('get_claude_config')
26
+ return (config?.authProvider as string | null) ?? null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ // Provider-specific model strings (simplified from TUI's modelStrings.ts)
33
+ // Desktop uses these for display labels when the full TUI module isn't loaded.
34
+ export const DESKTOP_MODEL_STRINGS = {
35
+ opus46: 'claude-opus-4-6-20250514',
36
+ sonnet46: 'claude-sonnet-4-6-20250608',
37
+ haiku45: 'claude-haiku-4-5-20250522',
38
+ opus45: 'claude-opus-4-5-20241022',
39
+ sonnet45: 'claude-sonnet-4-5-20241022',
40
+ haiku35: 'claude-haiku-3-5-20241022',
41
+ } as const
42
+
43
+ /**
44
+ * Build ModelOption[] for a given provider and subscription tier.
45
+ * This mirrors the logic in TUI's getModelOptions() but without the
46
+ * full TUI bootstrap/graphbook dependency tree.
47
+ *
48
+ * @param provider - The active API provider from config
49
+ * @param isSubscriber - Whether the user has a claude.ai subscription
50
+ * @param subscriptionType - 'max' | 'pro' | 'team' | null
51
+ * @param fastMode - Whether fast mode is enabled
52
+ * @param availableModels - Existing model list from cc-haha (supplemental)
53
+ */
54
+ export function buildModelOptions(
55
+ provider: string | null,
56
+ _isSubscriber: boolean,
57
+ subscriptionType: string | null,
58
+ fastMode: boolean,
59
+ availableModels: ModelInfo[],
60
+ ): ModelOption[] {
61
+ const opts: ModelOption[] = []
62
+
63
+ // Default always first
64
+ opts.push({
65
+ value: null,
66
+ label: 'Default (recommended)',
67
+ description: getDefaultDescription(subscriptionType),
68
+ })
69
+
70
+ if (!provider) {
71
+ return opts
72
+ }
73
+
74
+ const is3P = provider !== 'firstParty'
75
+
76
+ switch (provider) {
77
+ case 'firstParty': {
78
+ // Sonnet 4.6 is default for PAYG / non-subscriber
79
+ opts.push(makeSonnet46(is3P))
80
+ // Sonnet 1M if available
81
+ opts.push(makeSonnet46_1M(is3P))
82
+ // Opus 4.6 alternatives
83
+ if (isOpus1mMergeEnabled(_isSubscriber, subscriptionType)) {
84
+ opts.push(makeOpus46_1M(fastMode, is3P))
85
+ } else {
86
+ opts.push(makeOpus46(fastMode, is3P))
87
+ }
88
+ // Haiku
89
+ opts.push(makeHaiku45(is3P))
90
+ break
91
+ }
92
+
93
+ case 'openai': {
94
+ // Map to GPT models for display
95
+ opts.push({ value: 'gpt-5.4', label: 'GPT-5.4', description: 'GPT-5.4 · Recommended for most coding tasks' })
96
+ opts.push({ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'GPT-5.4 Mini · Fastest OpenAI option' })
97
+ break
98
+ }
99
+
100
+ case 'opencode': {
101
+ // OpenCode models - use available list or fall back to defaults
102
+ if (availableModels.length > 0) {
103
+ for (const m of availableModels) {
104
+ opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'OpenCode model' })
105
+ }
106
+ } else {
107
+ // Fallback defaults from DESKTOP_MODEL_STRINGS
108
+ opts.push({ value: 'big-pickle', label: 'Big Pickle', description: '旗舰模型,限时免费,适合复杂任务' })
109
+ opts.push({ value: 'gpt-5-nano', label: 'GPT 5 Nano', description: '永久免费,轻量快速,隐私安全' })
110
+ }
111
+ break
112
+ }
113
+
114
+ case 'nvidia': {
115
+ if (availableModels.length > 0) {
116
+ for (const m of availableModels) {
117
+ opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'NVIDIA NIM model' })
118
+ }
119
+ }
120
+ break
121
+ }
122
+
123
+ case 'local': {
124
+ // Local models come from availableModels
125
+ for (const m of availableModels) {
126
+ opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'Local model' })
127
+ }
128
+ break
129
+ }
130
+
131
+ case 'openrouter': {
132
+ if (availableModels.length > 0) {
133
+ for (const m of availableModels) {
134
+ opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'OpenRouter model' })
135
+ }
136
+ }
137
+ break
138
+ }
139
+
140
+ default:
141
+ // For unknown providers, supplement with availableModels
142
+ for (const m of availableModels) {
143
+ opts.push({ value: m.id, label: m.name || m.id, description: m.description || 'Model' })
144
+ }
145
+ break
146
+ }
147
+
148
+ return opts
149
+ }
150
+
151
+ function getDefaultDescription(subscriptionType: string | null): string {
152
+ if (subscriptionType === 'max') return 'Opus 4.6 · Most capable for complex work'
153
+ if (subscriptionType === 'pro') return 'Sonnet 4.6 · Best for everyday tasks'
154
+ return 'Sonnet 4.6 · Best for everyday tasks'
155
+ }
156
+
157
+ function isOpus1mMergeEnabled(_isSubscriber: boolean, subscriptionType: string | null): boolean {
158
+ // Only enable 1M for Max/Team Premium subscribers on firstParty
159
+ if (subscriptionType === 'max' || subscriptionType === 'team') return true
160
+ return false
161
+ }
162
+
163
+ function makeSonnet46(is3P: boolean): ModelOption {
164
+ return {
165
+ value: 'sonnet',
166
+ label: 'Sonnet',
167
+ description: `Sonnet 4.6 · Best for everyday tasks${is3P ? '' : ' · $3 / M input, $15 / M output'}`,
168
+ descriptionForModel: 'Sonnet 4.6 - best for everyday tasks',
169
+ }
170
+ }
171
+
172
+ function makeSonnet46_1M(is3P: boolean): ModelOption {
173
+ return {
174
+ value: 'sonnet[1m]',
175
+ label: 'Sonnet (1M context)',
176
+ description: `Sonnet 4.6 with 1M context window${is3P ? '' : ' · $3 / M input, $15 / M output'}`,
177
+ descriptionForModel: 'Sonnet 4.6 with 1M context window - for long sessions',
178
+ }
179
+ }
180
+
181
+ function makeOpus46(fastMode: boolean, is3P: boolean): ModelOption {
182
+ const suffix = fastMode && !is3P ? ' · ⚡ $7.50 / M input, $37.50 / M output' : ''
183
+ return {
184
+ value: 'opus',
185
+ label: 'Opus',
186
+ description: `Opus 4.6 · Most capable for complex work${suffix}`,
187
+ descriptionForModel: 'Opus 4.6 - most capable for complex work',
188
+ }
189
+ }
190
+
191
+ function makeOpus46_1M(fastMode: boolean, is3P: boolean): ModelOption {
192
+ const suffix = fastMode && !is3P ? ' · ⚡ $7.50 / M input, $37.50 / M output' : ''
193
+ return {
194
+ value: 'opus[1m]',
195
+ label: 'Opus (1M context)',
196
+ description: `Opus 4.6 with 1M context${suffix}`,
197
+ descriptionForModel: 'Opus 4.6 with 1M context - most capable for complex work',
198
+ }
199
+ }
200
+
201
+ function makeHaiku45(is3P: boolean): ModelOption {
202
+ return {
203
+ value: 'haiku',
204
+ label: 'Haiku',
205
+ description: `Haiku 4.5 · Fastest for quick answers${is3P ? '' : ' · $0.08 / M input, $0.40 / M output'}`,
206
+ descriptionForModel: 'Haiku 4.5 - fastest for quick answers',
207
+ }
208
+ }
desktop/src/lib/tuiConversation.ts ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * TUI-style conversation module for desktop.
3
+ *
4
+ * Reuses TUI's approach of directly calling provider APIs via HTTP,
5
+ * without going through the cc-haha sidecar or CLI subprocess.
6
+ *
7
+ * Supports all provider types:
8
+ * - Anthropic protocol: firstParty, openrouter, openai, local
9
+ * - OpenAI protocol: nvidia (NIM), opencode
10
+ *
11
+ * Config is read from ~/.claude.json via getTuiConfig().
12
+ */
13
+
14
+ import { getTuiConfig } from '../api/config'
15
+
16
+ // ─── Types ──────────────────────────────────────────────────────────────────
17
+
18
+ export type ConversationMessage = {
19
+ role: 'user' | 'assistant'
20
+ content: string
21
+ }
22
+
23
+ export type ConversationStreamEvent =
24
+ | { type: 'text_delta'; text: string }
25
+ | { type: 'thinking_delta'; thinking: string }
26
+ | { type: 'content_block_start'; index: number; blockType: string }
27
+ | { type: 'content_block_stop'; index: number }
28
+ | { type: 'message_stop'; usage?: { input_tokens?: number; output_tokens?: number } }
29
+ | { type: 'error'; message: string }
30
+ | { type: 'done' }
31
+
32
+ export type ConversationResult = {
33
+ content: string
34
+ usage?: { input_tokens: number; output_tokens: number }
35
+ }
36
+
37
+ // ─── Provider client factory ────────────────────────────────────────────────
38
+
39
+ type ProviderClientConfig = {
40
+ baseUrl: string
41
+ apiKey: string
42
+ model: string
43
+ /** Anthropic Messages API vs OpenAI Chat Completions */
44
+ protocol: 'anthropic' | 'openai'
45
+ }
46
+
47
+ async function resolveProviderConfig(model?: string): Promise<ProviderClientConfig> {
48
+ const config = await getTuiConfig()
49
+ const authProvider = (config.authProvider as string) || 'anthropic'
50
+
51
+ // Resolve API key and base URL based on provider
52
+ switch (authProvider) {
53
+ case 'openrouter': {
54
+ const apiKey = config.openRouterApiKey as string
55
+ if (!apiKey) throw new Error('OpenRouter API key not configured. Run /login first.')
56
+ return {
57
+ baseUrl: (process.env as Record<string, string>).OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
58
+ apiKey,
59
+ model: model || 'anthropic/claude-sonnet-4-6',
60
+ protocol: 'anthropic',
61
+ }
62
+ }
63
+
64
+ case 'nvidia': {
65
+ const apiKey = config.nvidiaApiKey as string
66
+ if (!apiKey) throw new Error('NVIDIA NIM API key not configured. Run /login first.')
67
+ const nvidiaBaseUrl = (config.nvidiaBaseUrl as string) || 'https://integrate.api.nvidia.com/v1'
68
+ return {
69
+ baseUrl: nvidiaBaseUrl,
70
+ apiKey,
71
+ model: model || (config.nvidiaModel as string) || 'nvidia/llama-3.1-nemotron-70b-instruct',
72
+ protocol: 'openai',
73
+ }
74
+ }
75
+
76
+ case 'opencode': {
77
+ const apiKey = config.openCodeApiKey as string
78
+ return {
79
+ baseUrl: 'https://opencode.ai/zen/v1',
80
+ apiKey: apiKey || 'public',
81
+ model: model || (config.openCodeModelName as string) || 'big-pickle',
82
+ protocol: 'openai',
83
+ }
84
+ }
85
+
86
+ case 'openai': {
87
+ const apiKey = (config.openAiApiKey as string) || (config.openAiAccessToken as string)
88
+ if (!apiKey) throw new Error('OpenAI API key not configured.')
89
+ return {
90
+ baseUrl: (process.env as Record<string, string>).OPENAI_BASE_URL || 'https://api.openai.com/v1',
91
+ apiKey,
92
+ model: model || 'gpt-5.4-codex',
93
+ protocol: 'openai',
94
+ }
95
+ }
96
+
97
+ case 'local': {
98
+ const localBaseUrl = config.localBaseUrl as string
99
+ if (!localBaseUrl) throw new Error('Local provider base URL not configured.')
100
+ return {
101
+ baseUrl: localBaseUrl,
102
+ apiKey: 'local-model',
103
+ model: model || (config.localModelName as string) || 'local-model',
104
+ protocol: 'openai', // Most local providers use OpenAI-compatible API
105
+ }
106
+ }
107
+
108
+ default: {
109
+ // First-party Anthropic
110
+ const apiKey = config.anthropicApiKey as string
111
+ if (!apiKey) throw new Error('Anthropic API key not configured.')
112
+ return {
113
+ baseUrl: (process.env as Record<string, string>).ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1',
114
+ apiKey,
115
+ model: model || 'claude-sonnet-4-6',
116
+ protocol: 'anthropic',
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ // ─── HTTP helpers ────────────────────────────────────────────────────────────
123
+
124
+ function anthropicHeaders(apiKey: string): Record<string, string> {
125
+ return {
126
+ 'Content-Type': 'application/json',
127
+ 'x-api-key': apiKey,
128
+ 'anthropic-version': '2023-06-01',
129
+ 'User-Agent': 'versperclaw-desktop/1.0',
130
+ }
131
+ }
132
+
133
+ function openaiHeaders(apiKey: string): Record<string, string> {
134
+ return {
135
+ 'Content-Type': 'application/json',
136
+ Authorization: `Bearer ${apiKey}`,
137
+ 'User-Agent': 'versperclaw-desktop/1.0',
138
+ }
139
+ }
140
+
141
+ // ─── OpenAI protocol streaming ───────────────────────────────────────────────
142
+
143
+ async function* streamOpenAI(
144
+ config: ProviderClientConfig,
145
+ messages: ConversationMessage[],
146
+ signal?: AbortSignal,
147
+ ): AsyncGenerator<ConversationStreamEvent> {
148
+ const body = {
149
+ model: config.model,
150
+ messages: messages.map(m => ({ role: m.role, content: m.content })),
151
+ stream: true,
152
+ }
153
+
154
+ let res: Response
155
+ try {
156
+ res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/chat/completions`, {
157
+ method: 'POST',
158
+ headers: openaiHeaders(config.apiKey),
159
+ body: JSON.stringify(body),
160
+ signal,
161
+ })
162
+ } catch (err) {
163
+ const name = err instanceof TypeError ? err.name : ''
164
+ const msg = err instanceof Error ? err.message : String(err)
165
+ console.error('[tuiConversation] OpenAI fetch failed:', { name, message: msg, url: config.baseUrl, model: config.model })
166
+ yield { type: 'error', message: `Network error: ${msg}${name ? ` (${name})` : ''}` }
167
+ yield { type: 'done' }
168
+ return
169
+ }
170
+
171
+ if (!res.ok) {
172
+ const errBody = await res.text().catch(() => '')
173
+ yield { type: 'error', message: `API error ${res.status}: ${errBody}` }
174
+ yield { type: 'done' }
175
+ return
176
+ }
177
+
178
+ if (!res.body) {
179
+ yield { type: 'error', message: 'No response body' }
180
+ yield { type: 'done' }
181
+ return
182
+ }
183
+
184
+ const decoder = new TextDecoder()
185
+ const reader = res.body.getReader()
186
+ let buffer = ''
187
+
188
+ yield { type: 'content_block_start', index: 0, blockType: 'text' }
189
+
190
+ while (true) {
191
+ if (signal?.aborted) break
192
+ const { done, value } = await reader.read()
193
+ if (done) break
194
+
195
+ buffer += decoder.decode(value, { stream: true })
196
+ const lines = buffer.split('\n')
197
+ buffer = lines.pop() || ''
198
+
199
+ for (const line of lines) {
200
+ if (!line.startsWith('data: ')) continue
201
+ const raw = line.slice(6).trim()
202
+ if (raw === '[DONE]') {
203
+ yield { type: 'content_block_stop', index: 0 }
204
+ yield { type: 'done' }
205
+ return
206
+ }
207
+
208
+ try {
209
+ const chunk = JSON.parse(raw)
210
+ const delta = chunk.choices?.[0]?.delta
211
+ if (!delta) continue
212
+
213
+ if (delta.content) {
214
+ yield { type: 'text_delta', text: delta.content }
215
+ }
216
+ if (delta.reasoning_content) {
217
+ yield { type: 'thinking_delta', thinking: delta.reasoning_content }
218
+ }
219
+ } catch {
220
+ // Skip malformed JSON
221
+ }
222
+ }
223
+ }
224
+
225
+ yield { type: 'content_block_stop', index: 0 }
226
+ yield { type: 'message_stop' }
227
+ yield { type: 'done' }
228
+ }
229
+
230
+ // ─── Public API ─────────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Send a conversation message and stream the response.
234
+ * Works for all provider types, bypassing the cc-haha sidecar.
235
+ *
236
+ * Usage:
237
+ * ```ts
238
+ * for await (const event of sendMessage([...messages])) {
239
+ * if (event.type === 'text_delta') { updateUI(event.text) }
240
+ * if (event.type === 'done') { break }
241
+ * }
242
+ * ```
243
+ */
244
+ export async function* sendMessage(
245
+ messages: ConversationMessage[],
246
+ options?: {
247
+ model?: string
248
+ system?: string
249
+ signal?: AbortSignal
250
+ },
251
+ ): AsyncGenerator<ConversationStreamEvent> {
252
+ const config = await resolveProviderConfig(options?.model)
253
+
254
+ if (config.protocol === 'anthropic') {
255
+ // Re-implement Anthropic streaming inline with proper yield support
256
+ const body: Record<string, unknown> = {
257
+ model: config.model,
258
+ messages,
259
+ max_tokens: 4096,
260
+ stream: true,
261
+ }
262
+ if (options?.system) body.system = options.system
263
+
264
+ let res: Response
265
+ try {
266
+ res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/messages`, {
267
+ method: 'POST',
268
+ headers: anthropicHeaders(config.apiKey),
269
+ body: JSON.stringify(body),
270
+ signal: options?.signal,
271
+ })
272
+ } catch (err) {
273
+ const name = err instanceof TypeError ? err.name : ''
274
+ const msg = err instanceof Error ? err.message : String(err)
275
+ console.error('[tuiConversation] Anthropic fetch failed:', { name, message: msg, url: config.baseUrl, model: config.model })
276
+ yield { type: 'error', message: `Network error: ${msg}${name ? ` (${name})` : ''}` }
277
+ yield { type: 'done' }
278
+ return
279
+ }
280
+
281
+ if (!res.ok) {
282
+ const errBody = await res.text().catch(() => '')
283
+ yield { type: 'error', message: `API error ${res.status}: ${errBody}` }
284
+ yield { type: 'done' }
285
+ return
286
+ }
287
+
288
+ if (!res.body) {
289
+ yield { type: 'error', message: 'No response body' }
290
+ yield { type: 'done' }
291
+ return
292
+ }
293
+
294
+ const decoder = new TextDecoder()
295
+ const reader = res.body.getReader()
296
+ let buffer = ''
297
+ let usage: { input_tokens?: number; output_tokens?: number } | undefined
298
+
299
+ try {
300
+ while (true) {
301
+ if (options?.signal?.aborted) break
302
+ const { done, value } = await reader.read()
303
+ if (done) break
304
+
305
+ buffer += decoder.decode(value, { stream: true })
306
+ const lines = buffer.split('\n')
307
+ buffer = lines.pop() || ''
308
+
309
+ for (const line of lines) {
310
+ if (line.startsWith('data: ')) {
311
+ const raw = line.slice(6).trim()
312
+ if (!raw) continue
313
+
314
+ try {
315
+ const data = JSON.parse(raw)
316
+
317
+ if (data.type === 'content_block_start') {
318
+ yield {
319
+ type: 'content_block_start',
320
+ index: data.index ?? 0,
321
+ blockType: data.content_block?.type ?? 'text',
322
+ }
323
+ } else if (data.type === 'content_block_delta') {
324
+ const delta = data.delta
325
+ if (delta?.type === 'text_delta' && delta.text) {
326
+ yield { type: 'text_delta', text: delta.text }
327
+ } else if (delta?.type === 'thinking_delta' && delta.thinking) {
328
+ yield { type: 'thinking_delta', thinking: delta.thinking }
329
+ }
330
+ } else if (data.type === 'content_block_stop') {
331
+ yield { type: 'content_block_stop', index: data.index ?? 0 }
332
+ } else if (data.type === 'message_start' && data.message?.usage) {
333
+ usage = data.message.usage
334
+ } else if (data.type === 'message_delta' && data.delta?.stop_reason) {
335
+ if (data.usage) usage = data.usage
336
+ } else if (data.type === 'message_stop') {
337
+ yield { type: 'message_stop', usage }
338
+ yield { type: 'done' }
339
+ return
340
+ }
341
+ } catch {
342
+ // Skip malformed JSON
343
+ }
344
+ }
345
+ }
346
+ }
347
+ } finally {
348
+ reader.releaseLock()
349
+ }
350
+
351
+ if (!options?.signal?.aborted) {
352
+ yield { type: 'message_stop', usage }
353
+ yield { type: 'done' }
354
+ }
355
+ } else {
356
+ // OpenAI protocol
357
+ yield* streamOpenAI(config, messages, options?.signal)
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Non-streaming send - returns the complete result.
363
+ */
364
+ export async function sendMessageSync(
365
+ messages: ConversationMessage[],
366
+ options?: {
367
+ model?: string
368
+ system?: string
369
+ signal?: AbortSignal
370
+ },
371
+ ): Promise<ConversationResult> {
372
+ const config = await resolveProviderConfig(options?.model)
373
+
374
+ if (config.protocol === 'anthropic') {
375
+ const body: Record<string, unknown> = {
376
+ model: config.model,
377
+ messages,
378
+ max_tokens: 4096,
379
+ }
380
+ if (options?.system) body.system = options.system
381
+
382
+ const res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/messages`, {
383
+ method: 'POST',
384
+ headers: anthropicHeaders(config.apiKey),
385
+ body: JSON.stringify(body),
386
+ signal: options?.signal,
387
+ })
388
+
389
+ if (!res.ok) {
390
+ const errBody = await res.text().catch(() => '')
391
+ throw new Error(`API error ${res.status}: ${errBody}`)
392
+ }
393
+
394
+ const data = (await res.json()) as {
395
+ content: Array<{ type: string; text?: string }>
396
+ usage?: { input_tokens: number; output_tokens: number }
397
+ }
398
+
399
+ const text = data.content
400
+ ?.filter((b) => b.type === 'text')
401
+ .map((b) => b.text)
402
+ .join('\n') ?? ''
403
+
404
+ return { content: text, usage: data.usage }
405
+ }
406
+
407
+ // OpenAI protocol
408
+ const body = {
409
+ model: config.model,
410
+ messages: messages.map(m => ({ role: m.role, content: m.content })),
411
+ }
412
+
413
+ const res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/chat/completions`, {
414
+ method: 'POST',
415
+ headers: openaiHeaders(config.apiKey),
416
+ body: JSON.stringify(body),
417
+ signal: options?.signal,
418
+ })
419
+
420
+ if (!res.ok) {
421
+ const errBody = await res.text().catch(() => '')
422
+ throw new Error(`API error ${res.status}: ${errBody}`)
423
+ }
424
+
425
+ const data = (await res.json()) as {
426
+ choices: Array<{ message: { content: string | null } }>
427
+ usage?: { prompt_tokens: number; completion_tokens: number }
428
+ }
429
+
430
+ return {
431
+ content: data.choices?.[0]?.message?.content ?? '',
432
+ usage: data.usage ? { input_tokens: data.usage.prompt_tokens, output_tokens: data.usage.completion_tokens } : undefined,
433
+ }
434
+ }
desktop/src/stores/chatStore.ts CHANGED
@@ -9,6 +9,8 @@ import { useTabStore } from './tabStore'
9
  import { randomSpinnerVerb } from '../config/spinnerVerbs'
10
  import { notifyDesktop } from '../lib/desktopNotifications'
11
  import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
 
 
12
  import { AGENT_LIFECYCLE_TYPES } from '../types/team'
13
  import type { ComposerAttachment } from '../lib/composerAttachments'
14
  import type { MessageEntry } from '../types/session'
@@ -145,6 +147,7 @@ type ChatStore = {
145
  clearComposerDraft: (sessionId: string) => void
146
  clearMessages: (sessionId: string) => void
147
  handleServerMessage: (sessionId: string, msg: ServerMessage) => void
 
148
  }
149
 
150
  const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
@@ -612,6 +615,21 @@ function mergeSlashCommandUpdates(
612
  return [...merged.values()]
613
  }
614
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  async function fetchAndMapSessionHistory(sessionId: string) {
616
  const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId)
617
  const uiMessages = mapHistoryMessagesToUiMessages(messages)
@@ -663,13 +681,15 @@ export const useChatStore = create<ChatStore>((set, get) => ({
663
  get().handleServerMessage(sessionId, msg)
664
  })
665
 
666
- const runtimeSelection = useSessionRuntimeStore.getState().selections[sessionId]
667
- if (runtimeSelection) {
668
- wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection })
669
- }
670
- if (!sessionId.startsWith('__') && !useTeamStore.getState().getMemberBySessionId(sessionId)) {
671
- wsManager.send(sessionId, { type: 'prewarm_session' })
672
- }
 
 
673
 
674
  get().loadHistory(sessionId)
675
  sessionsApi.getSlashCommands(sessionId)
@@ -811,7 +831,8 @@ export const useChatStore = create<ChatStore>((set, get) => ({
811
  return
812
  }
813
 
814
- wsManager.send(sessionId, { type: 'user_message', content, attachments })
 
815
  },
816
 
817
  respondToPermission: (sessionId, requestId, allowed, options) => {
@@ -852,29 +873,197 @@ export const useChatStore = create<ChatStore>((set, get) => ({
852
  },
853
 
854
  stopGeneration: (sessionId) => {
855
- wsManager.send(sessionId, { type: 'stop_generation' })
 
 
856
  if (pendingDeltaBySession.has(sessionId)) {
857
  const text = consumePendingDelta(sessionId)
858
  set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
859
  }
860
- set((s) => {
861
- const session = s.sessions[sessionId]
862
- if (!session) return s
863
- if (session.elapsedTimer) clearInterval(session.elapsedTimer)
864
- return {
865
- sessions: {
866
- ...s.sessions,
867
- [sessionId]: {
868
- ...session,
869
- chatState: 'idle',
870
- pendingPermission: null,
871
- pendingComputerUsePermission: null,
872
- apiRetry: null,
873
- elapsedTimer: null,
874
- },
875
- },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876
  }
877
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878
  },
879
 
880
  loadHistory: async (sessionId) => {
 
9
  import { randomSpinnerVerb } from '../config/spinnerVerbs'
10
  import { notifyDesktop } from '../lib/desktopNotifications'
11
  import { deriveSessionTitle, isPlaceholderSessionTitle } from '../lib/sessionTitle'
12
+ import * as tuiConversation from '../lib/tuiConversation'
13
+ import type { ConversationMessage } from '../lib/tuiConversation'
14
  import { AGENT_LIFECYCLE_TYPES } from '../types/team'
15
  import type { ComposerAttachment } from '../lib/composerAttachments'
16
  import type { MessageEntry } from '../types/session'
 
147
  clearComposerDraft: (sessionId: string) => void
148
  clearMessages: (sessionId: string) => void
149
  handleServerMessage: (sessionId: string, msg: ServerMessage) => void
150
+ startDirectConversation: (sessionId: string, content: string) => Promise<void>
151
  }
152
 
153
  const TASK_TOOL_NAMES = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList', 'TodoWrite'])
 
615
  return [...merged.values()]
616
  }
617
 
618
+ function buildConversationHistory(messages: UIMessage[]): ConversationMessage[] {
619
+ const history: ConversationMessage[] = []
620
+ for (const msg of messages) {
621
+ if (msg.type === 'user_text') {
622
+ const record = msg as Extract<UIMessage, { type: 'user_text' }>
623
+ const text = (record as { modelContent?: string }).modelContent || record.content
624
+ history.push({ role: 'user', content: text })
625
+ } else if (msg.type === 'assistant_text') {
626
+ const text = (msg as Extract<UIMessage, { type: 'assistant_text' }>).content
627
+ history.push({ role: 'assistant', content: text })
628
+ }
629
+ }
630
+ return history
631
+ }
632
+
633
  async function fetchAndMapSessionHistory(sessionId: string) {
634
  const { messages, taskNotifications } = await sessionsApi.getMessages(sessionId)
635
  const uiMessages = mapHistoryMessagesToUiMessages(messages)
 
681
  get().handleServerMessage(sessionId, msg)
682
  })
683
 
684
+ // Direct API mode: no sidecar runtime config to send
685
+ // const runtimeSelection = useSessionRuntimeStore.getState().selections[sessionId]
686
+ // if (runtimeSelection) {
687
+ // wsManager.send(sessionId, { type: 'set_runtime_config', ...runtimeSelection })
688
+ // }
689
+ // Direct API mode: no CLI subprocess to prewarm
690
+ // if (!sessionId.startsWith('__') && !useTeamStore.getState().getMemberBySessionId(sessionId)) {
691
+ // wsManager.send(sessionId, { type: 'prewarm_session' })
692
+ // }
693
 
694
  get().loadHistory(sessionId)
695
  sessionsApi.getSlashCommands(sessionId)
 
831
  return
832
  }
833
 
834
+ // Use direct provider API instead of sidecar CLI subprocess
835
+ get().startDirectConversation(sessionId, modelFacingContent)
836
  },
837
 
838
  respondToPermission: (sessionId, requestId, allowed, options) => {
 
873
  },
874
 
875
  stopGeneration: (sessionId) => {
876
+ // Direct transport: no WebSocket to send stop signal to.
877
+ // For now, aborts are not supported in direct mode.
878
+ // The session will complete the current response naturally.
879
  if (pendingDeltaBySession.has(sessionId)) {
880
  const text = consumePendingDelta(sessionId)
881
  set((s) => ({ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({ streamingText: sess.streamingText + text })) }))
882
  }
883
+ clearPendingTaskToolUseIds(sessionId)
884
+ clearPendingToolParentUseIds(sessionId)
885
+ },
886
+
887
+ startDirectConversation: async (sessionId, _content) => {
888
+ const session = get().sessions[sessionId]
889
+ if (!session) return
890
+
891
+ // Build conversation history from existing messages.
892
+ // The user's new message is already in session.messages (added by sendMessage's
893
+ // synchronous set()), so no need to push it again.
894
+ const history = buildConversationHistory(session.messages)
895
+
896
+ // Use runtime-selected model if available (set via desktop session settings)
897
+ const runtimeSelection = useSessionRuntimeStore.getState().selections[sessionId]
898
+ const sendOptions: { model?: string } = {}
899
+ if (runtimeSelection?.modelId) {
900
+ sendOptions.model = runtimeSelection.modelId
901
+ }
902
+
903
+ try {
904
+ const stream = tuiConversation.sendMessage(history, sendOptions)
905
+ for await (const event of stream) {
906
+ switch (event.type) {
907
+ case 'content_block_start': {
908
+ if (event.blockType === 'text') {
909
+ set((s) => ({
910
+ sessions: updateSessionIn(s.sessions, sessionId, () => ({
911
+ chatState: 'streaming',
912
+ apiRetry: null,
913
+ })),
914
+ }))
915
+ }
916
+ break
917
+ }
918
+
919
+ case 'text_delta': {
920
+ appendPendingDelta(sessionId, event.text)
921
+ if (!flushTimerBySession.has(sessionId)) {
922
+ const timer = setTimeout(() => {
923
+ const text = pendingDeltaBySession.get(sessionId) ?? ''
924
+ pendingDeltaBySession.delete(sessionId)
925
+ flushTimerBySession.delete(sessionId)
926
+ set((s) => ({
927
+ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
928
+ streamingText: sess.streamingText + text,
929
+ })),
930
+ }))
931
+ }, 50)
932
+ flushTimerBySession.set(sessionId, timer)
933
+ }
934
+ break
935
+ }
936
+
937
+ case 'thinking_delta': {
938
+ set((s) => {
939
+ const sess = s.sessions[sessionId] ?? createDefaultSessionState()
940
+ const pendingText = `${sess.streamingText}${consumePendingDelta(sessionId)}`
941
+ let base = sess.messages
942
+ if (pendingText.trim()) {
943
+ base = appendAssistantTextMessage(base, pendingText, Date.now())
944
+ }
945
+ const last = base[base.length - 1]
946
+ if (last && last.type === 'thinking') {
947
+ const updated = [...base]
948
+ updated[updated.length - 1] = {
949
+ ...last,
950
+ content: last.content + event.thinking,
951
+ }
952
+ return {
953
+ sessions: updateSessionIn(s.sessions, sessionId, () => ({
954
+ messages: updated,
955
+ chatState: 'thinking',
956
+ activeThinkingId: last.id,
957
+ streamingText: '',
958
+ })),
959
+ }
960
+ }
961
+ const id = nextId()
962
+ return {
963
+ sessions: updateSessionIn(s.sessions, sessionId, () => ({
964
+ messages: [
965
+ ...base,
966
+ { id, type: 'thinking', content: event.thinking, timestamp: Date.now() },
967
+ ],
968
+ chatState: 'thinking',
969
+ activeThinkingId: id,
970
+ streamingText: '',
971
+ })),
972
+ }
973
+ })
974
+ break
975
+ }
976
+
977
+ case 'content_block_stop': {
978
+ const pendingText = consumePendingDelta(sessionId)
979
+ set((s) => ({
980
+ sessions: updateSessionIn(s.sessions, sessionId, (sess) => {
981
+ const accumulatedText = `${sess.streamingText}${pendingText}`
982
+ if (!accumulatedText.trim()) return {}
983
+ return {
984
+ messages: appendAssistantTextMessage(sess.messages, accumulatedText, Date.now()),
985
+ streamingText: '',
986
+ }
987
+ }),
988
+ }))
989
+ break
990
+ }
991
+
992
+ case 'message_stop': {
993
+ // Flush any remaining pending delta
994
+ const remainingText = consumePendingDelta(sessionId)
995
+ if (remainingText.trim()) {
996
+ set((s) => ({
997
+ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
998
+ messages: appendAssistantTextMessage(
999
+ sess.messages,
1000
+ `${sess.streamingText}${remainingText}`,
1001
+ Date.now(),
1002
+ ),
1003
+ streamingText: '',
1004
+ })),
1005
+ }))
1006
+ }
1007
+ const sess = get().sessions[sessionId]
1008
+ if (sess?.elapsedTimer) {
1009
+ clearInterval(sess.elapsedTimer)
1010
+ }
1011
+ set((s) => ({
1012
+ sessions: updateSessionIn(s.sessions, sessionId, () => ({
1013
+ chatState: 'idle',
1014
+ elapsedTimer: null,
1015
+ statusVerb: '',
1016
+ })),
1017
+ }))
1018
+ break
1019
+ }
1020
+
1021
+ case 'done':
1022
+ break
1023
+
1024
+ case 'error': {
1025
+ set((s) => ({
1026
+ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
1027
+ chatState: 'idle',
1028
+ messages: [
1029
+ ...sess.messages,
1030
+ {
1031
+ id: nextId(),
1032
+ type: 'error',
1033
+ message: event.message,
1034
+ code: 'CLI_ERROR',
1035
+ timestamp: Date.now(),
1036
+ } as UIMessage,
1037
+ ],
1038
+ })),
1039
+ }))
1040
+ break
1041
+ }
1042
+ }
1043
  }
1044
+ } catch (err) {
1045
+ const message = err instanceof Error ? err.message : String(err)
1046
+ const sess = get().sessions[sessionId]
1047
+ if (sess?.elapsedTimer) {
1048
+ clearInterval(sess.elapsedTimer)
1049
+ }
1050
+ set((s) => ({
1051
+ sessions: updateSessionIn(s.sessions, sessionId, (sess) => ({
1052
+ chatState: 'idle',
1053
+ elapsedTimer: null,
1054
+ messages: [
1055
+ ...sess.messages,
1056
+ {
1057
+ id: nextId(),
1058
+ type: 'error',
1059
+ message,
1060
+ code: 'CLI_ERROR',
1061
+ timestamp: Date.now(),
1062
+ } as UIMessage,
1063
+ ],
1064
+ })),
1065
+ }))
1066
+ }
1067
  },
1068
 
1069
  loadHistory: async (sessionId) => {
desktop/src/stores/cliAuthStore.ts CHANGED
@@ -1,5 +1,13 @@
 
 
 
 
 
 
1
  import { create } from 'zustand'
2
- import { cliAuthApi, type AuthProvider, type CliAuthConfig } from '../api/cliAuth'
 
 
3
 
4
  type CliAuthStore = {
5
  authProvider: AuthProvider | null
@@ -38,16 +46,19 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
38
  fetchAuth: async () => {
39
  set({ isLoading: true, error: null })
40
  try {
41
- const config = await cliAuthApi.get()
 
 
 
42
  set({
43
- authProvider: config.authProvider,
44
- nvidiaApiKey: config.nvidiaApiKey || null,
45
- openRouterApiKey: config.openRouterApiKey || null,
46
- openAiApiKey: config.openAiApiKey || config.openAiAccessToken || null,
47
- openCodeApiKey: config.openCodeApiKey || null,
48
- openCodeModelName: config.openCodeModelName || null,
49
- localBaseUrl: config.localBaseUrl || null,
50
- localModelName: config.localModelName || null,
51
  isLoading: false,
52
  })
53
  } catch (err) {
@@ -58,8 +69,8 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
58
  setAuthProvider: async (provider: AuthProvider) => {
59
  set({ isLoading: true, error: null })
60
  try {
61
- await cliAuthApi.update({ authProvider: provider })
62
- await cliAuthApi.invalidateCache()
63
  set({ authProvider: provider, isLoading: false })
64
  } catch (err) {
65
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
@@ -69,8 +80,11 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
69
  saveNvidiaApiKey: async (apiKey: string) => {
70
  set({ isLoading: true, error: null })
71
  try {
72
- await cliAuthApi.update({ authProvider: 'nvidia', nvidiaApiKey: apiKey })
73
- await cliAuthApi.invalidateCache()
 
 
 
74
  set({ authProvider: 'nvidia', nvidiaApiKey: apiKey, isLoading: false })
75
  } catch (err) {
76
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
@@ -80,8 +94,11 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
80
  saveOpenRouterApiKey: async (apiKey: string) => {
81
  set({ isLoading: true, error: null })
82
  try {
83
- await cliAuthApi.update({ authProvider: 'openrouter', openRouterApiKey: apiKey })
84
- await cliAuthApi.invalidateCache()
 
 
 
85
  set({ authProvider: 'openrouter', openRouterApiKey: apiKey, isLoading: false })
86
  } catch (err) {
87
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
@@ -91,8 +108,12 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
91
  saveOpenAIApiKey: async (apiKey: string) => {
92
  set({ isLoading: true, error: null })
93
  try {
94
- await cliAuthApi.update({ authProvider: 'openai', openAiApiKey: apiKey })
95
- await cliAuthApi.invalidateCache()
 
 
 
 
96
  set({ authProvider: 'openai', openAiApiKey: apiKey, isLoading: false })
97
  } catch (err) {
98
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
@@ -102,10 +123,12 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
102
  saveOpenCodeApiKey: async (apiKey: string, modelName?: string) => {
103
  set({ isLoading: true, error: null })
104
  try {
105
- const updates: Record<string, unknown> = { authProvider: 'opencode', openCodeApiKey: apiKey }
106
- if (modelName) updates.openCodeModelName = modelName
107
- await cliAuthApi.update(updates as Partial<CliAuthConfig>)
108
- await cliAuthApi.invalidateCache()
 
 
109
  set({
110
  authProvider: 'opencode',
111
  openCodeApiKey: apiKey,
@@ -120,9 +143,18 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
120
  saveLocalModelConfig: async (baseUrl: string, modelName: string) => {
121
  set({ isLoading: true, error: null })
122
  try {
123
- await cliAuthApi.update({ authProvider: 'local', localBaseUrl: baseUrl, localModelName: modelName })
124
- await cliAuthApi.invalidateCache()
125
- set({ authProvider: 'local', localBaseUrl: baseUrl, localModelName: modelName, isLoading: false })
 
 
 
 
 
 
 
 
 
126
  } catch (err) {
127
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
128
  }
@@ -131,11 +163,33 @@ export const useCliAuthStore = create<CliAuthStore>((set) => ({
131
  clearAuth: async () => {
132
  set({ isLoading: true, error: null })
133
  try {
134
- await cliAuthApi.update({ authProvider: null })
135
- await cliAuthApi.invalidateCache()
136
- set({ authProvider: null, nvidiaApiKey: null, openRouterApiKey: null, openAiApiKey: null, openCodeApiKey: null, localBaseUrl: null, localModelName: null, isLoading: false })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  } catch (err) {
138
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
139
  }
140
  },
141
- }))
 
1
+ /**
2
+ * Desktop auth store that reads/writes ~/.claude.json directly via Rust Tauri commands.
3
+ * Mirrors TUI's src/utils/auth.ts save* functions.
4
+ *
5
+ * This removes the dependency on cc-haha's /api/cli-auth HTTP endpoint.
6
+ */
7
  import { create } from 'zustand'
8
+ import { getTuiConfig, saveTuiConfigPatch, clearConfigCache } from '../api/config'
9
+
10
+ type AuthProvider = 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
11
 
12
  type CliAuthStore = {
13
  authProvider: AuthProvider | null
 
46
  fetchAuth: async () => {
47
  set({ isLoading: true, error: null })
48
  try {
49
+ const config = await getTuiConfig()
50
+ // Map TUI's authProvider names to our store's names
51
+ // TUI uses 'anthropic' → our 'anthropic' means firstParty
52
+ const authProvider = config.authProvider ?? null
53
  set({
54
+ authProvider,
55
+ nvidiaApiKey: config.nvidiaApiKey ?? null,
56
+ openRouterApiKey: config.openRouterApiKey ?? null,
57
+ openAiApiKey: (config.openAiApiKey || config.openAiAccessToken) ?? null,
58
+ openCodeApiKey: config.openCodeApiKey ?? null,
59
+ openCodeModelName: config.openCodeModelName ?? null,
60
+ localBaseUrl: config.localBaseUrl ?? null,
61
+ localModelName: config.localModelName ?? null,
62
  isLoading: false,
63
  })
64
  } catch (err) {
 
69
  setAuthProvider: async (provider: AuthProvider) => {
70
  set({ isLoading: true, error: null })
71
  try {
72
+ await saveTuiConfigPatch({ authProvider: provider })
73
+ clearConfigCache()
74
  set({ authProvider: provider, isLoading: false })
75
  } catch (err) {
76
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
 
80
  saveNvidiaApiKey: async (apiKey: string) => {
81
  set({ isLoading: true, error: null })
82
  try {
83
+ await saveTuiConfigPatch({
84
+ authProvider: 'nvidia',
85
+ nvidiaApiKey: apiKey,
86
+ })
87
+ clearConfigCache()
88
  set({ authProvider: 'nvidia', nvidiaApiKey: apiKey, isLoading: false })
89
  } catch (err) {
90
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
 
94
  saveOpenRouterApiKey: async (apiKey: string) => {
95
  set({ isLoading: true, error: null })
96
  try {
97
+ await saveTuiConfigPatch({
98
+ authProvider: 'openrouter',
99
+ openRouterApiKey: apiKey,
100
+ })
101
+ clearConfigCache()
102
  set({ authProvider: 'openrouter', openRouterApiKey: apiKey, isLoading: false })
103
  } catch (err) {
104
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
 
108
  saveOpenAIApiKey: async (apiKey: string) => {
109
  set({ isLoading: true, error: null })
110
  try {
111
+ await saveTuiConfigPatch({
112
+ authProvider: 'openai',
113
+ openAiApiKey: apiKey,
114
+ openAiAccessToken: undefined, // Clear OAuth token when switching to API key
115
+ })
116
+ clearConfigCache()
117
  set({ authProvider: 'openai', openAiApiKey: apiKey, isLoading: false })
118
  } catch (err) {
119
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
 
123
  saveOpenCodeApiKey: async (apiKey: string, modelName?: string) => {
124
  set({ isLoading: true, error: null })
125
  try {
126
+ await saveTuiConfigPatch({
127
+ authProvider: 'opencode',
128
+ openCodeApiKey: apiKey || undefined,
129
+ openCodeModelName: modelName || undefined,
130
+ })
131
+ clearConfigCache()
132
  set({
133
  authProvider: 'opencode',
134
  openCodeApiKey: apiKey,
 
143
  saveLocalModelConfig: async (baseUrl: string, modelName: string) => {
144
  set({ isLoading: true, error: null })
145
  try {
146
+ await saveTuiConfigPatch({
147
+ authProvider: 'local',
148
+ localBaseUrl: baseUrl,
149
+ localModelName: modelName,
150
+ })
151
+ clearConfigCache()
152
+ set({
153
+ authProvider: 'local',
154
+ localBaseUrl: baseUrl,
155
+ localModelName: modelName,
156
+ isLoading: false,
157
+ })
158
  } catch (err) {
159
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
160
  }
 
163
  clearAuth: async () => {
164
  set({ isLoading: true, error: null })
165
  try {
166
+ // Clear by writing null values for all auth fields
167
+ await saveTuiConfigPatch({
168
+ authProvider: null,
169
+ primaryApiKey: undefined,
170
+ openAiApiKey: undefined,
171
+ openAiAccessToken: undefined,
172
+ openRouterApiKey: undefined,
173
+ nvidiaApiKey: undefined,
174
+ openCodeApiKey: undefined,
175
+ openCodeModelName: undefined,
176
+ localBaseUrl: undefined,
177
+ localModelName: undefined,
178
+ })
179
+ clearConfigCache()
180
+ set({
181
+ authProvider: null,
182
+ nvidiaApiKey: null,
183
+ openRouterApiKey: null,
184
+ openAiApiKey: null,
185
+ openCodeApiKey: null,
186
+ openCodeModelName: null,
187
+ localBaseUrl: null,
188
+ localModelName: null,
189
+ isLoading: false,
190
+ })
191
  } catch (err) {
192
  set({ isLoading: false, error: err instanceof Error ? err.message : String(err) })
193
  }
194
  },
195
+ }))
desktop/src/stores/providerStore.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
  OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
11
  OPENAI_OFFICIAL_PROVIDER_ID,
12
  } from '../constants/openaiOfficialProvider'
 
13
  import type {
14
  SavedProvider,
15
  CreateProviderInput,
@@ -48,6 +49,34 @@ function providerModelIds(provider: SavedProvider): Set<string> {
48
  )
49
  }
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  function resolveRuntimeRefreshSelection(
52
  provider: SavedProvider,
53
  activeId: string | null,
@@ -147,10 +176,11 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
147
  activateProvider: async (id) => {
148
  await providersApi.activate(id)
149
  await get().fetchProviders()
150
- // 更新默认 provider 时,同步刷新默认 model,避免 settings.json 里残留
151
- // provider model id 导致默认选择指向不存在的模型
152
  const settings = useSettingsStore.getState()
153
  if (id === OPENAI_OFFICIAL_PROVIDER_ID) {
 
154
  await settings.setModel(OPENAI_OFFICIAL_DEFAULT_MODEL_ID)
155
  await settings.fetchAll()
156
  return
@@ -158,10 +188,33 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
158
 
159
  const provider = get().providers.find((p) => p.id === id)
160
  if (!provider) return
 
 
 
 
 
 
 
 
 
 
 
 
161
  await settings.setModel(provider.models.main)
162
  await settings.fetchAll()
163
  },
164
 
 
 
 
 
 
 
 
 
 
 
 
165
  activateOfficial: async () => {
166
  await providersApi.activateOfficial()
167
  await get().fetchProviders()
 
10
  OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
11
  OPENAI_OFFICIAL_PROVIDER_ID,
12
  } from '../constants/openaiOfficialProvider'
13
+ import { saveTuiConfigPatch, clearConfigCache } from '../api/config'
14
  import type {
15
  SavedProvider,
16
  CreateProviderInput,
 
49
  )
50
  }
51
 
52
+ /** Map a sidecar SavedProvider to TUI ~/.claude.json authProvider fields */
53
+ type TuiProviderMapping = { authProvider: string; apiKeyField: string; apiKey?: string } | null
54
+ function mapSidecarToTuiProvider(provider: SavedProvider): TuiProviderMapping {
55
+ const apiFormat = provider.apiFormat || 'anthropic'
56
+ switch (apiFormat) {
57
+ case 'anthropic':
58
+ return { authProvider: 'anthropic', apiKeyField: 'anthropicApiKey', apiKey: provider.apiKey }
59
+ case 'openai': {
60
+ // Try to infer specific provider from base URL or name
61
+ const baseUrl = (provider.baseUrl || '').toLowerCase()
62
+ const name = (provider.name || '').toLowerCase()
63
+ if (baseUrl.includes('nvidia') || name.includes('nvidia')) {
64
+ return { authProvider: 'nvidia', apiKeyField: 'nvidiaApiKey', apiKey: provider.apiKey }
65
+ }
66
+ if (baseUrl.includes('openrouter') || name.includes('openrouter')) {
67
+ return { authProvider: 'openrouter', apiKeyField: 'openRouterApiKey', apiKey: provider.apiKey }
68
+ }
69
+ if (baseUrl.includes('opencode') || name.includes('opencode')) {
70
+ return { authProvider: 'opencode', apiKeyField: 'openCodeApiKey', apiKey: provider.apiKey }
71
+ }
72
+ // Generic OpenAI-compatible
73
+ return { authProvider: 'openai', apiKeyField: 'openAiApiKey', apiKey: provider.apiKey }
74
+ }
75
+ default:
76
+ return null
77
+ }
78
+ }
79
+
80
  function resolveRuntimeRefreshSelection(
81
  provider: SavedProvider,
82
  activeId: string | null,
 
176
  activateProvider: async (id) => {
177
  await providersApi.activate(id)
178
  await get().fetchProviders()
179
+ // 同步 provider 信息到 ~/.claude.json,这样 fetchProviderModels() / tuiConversation
180
+ // 可以直接通过 TUI 方式来读取配置,无需依赖 sidecar
181
  const settings = useSettingsStore.getState()
182
  if (id === OPENAI_OFFICIAL_PROVIDER_ID) {
183
+ await saveTuiConfigPatch({ authProvider: 'anthropic' })
184
  await settings.setModel(OPENAI_OFFICIAL_DEFAULT_MODEL_ID)
185
  await settings.fetchAll()
186
  return
 
188
 
189
  const provider = get().providers.find((p) => p.id === id)
190
  if (!provider) return
191
+
192
+ // Map sidecar apiFormat to TUI authProvider
193
+ const tuiProvider = mapSidecarToTuiProvider(provider)
194
+ const patch: Record<string, unknown> = {}
195
+ if (tuiProvider) {
196
+ patch.authProvider = tuiProvider.authProvider
197
+ if (tuiProvider.apiKey) patch[tuiProvider.apiKeyField] = tuiProvider.apiKey
198
+ }
199
+ patch.model = provider.models.main
200
+ await saveTuiConfigPatch(patch)
201
+ clearConfigCache()
202
+
203
  await settings.setModel(provider.models.main)
204
  await settings.fetchAll()
205
  },
206
 
207
+ activateOfficial: async () => {
208
+ await providersApi.activateOfficial()
209
+ await get().fetchProviders()
210
+ // 同步回 Anthropic first-party
211
+ await saveTuiConfigPatch({ authProvider: 'anthropic' })
212
+ clearConfigCache()
213
+ const settings = useSettingsStore.getState()
214
+ await settings.setModel(OFFICIAL_DEFAULT_MODEL_ID)
215
+ await settings.fetchAll()
216
+ },
217
+
218
  activateOfficial: async () => {
219
  await providersApi.activateOfficial()
220
  await get().fetchProviders()
desktop/src/stores/settingsStore.ts CHANGED
@@ -3,6 +3,8 @@ import { ApiError } from '../api/client'
3
  import { settingsApi } from '../api/settings'
4
  import { modelsApi } from '../api/models'
5
  import { h5AccessApi } from '../api/h5Access'
 
 
6
  import {
7
  isThemeMode,
8
  type AppMode,
@@ -170,23 +172,60 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
170
  set({ isLoading: true, error: null })
171
  try {
172
  const previousH5Access = get().h5Access
173
- const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult] = await Promise.all([
 
 
174
  settingsApi.getPermissionMode(),
175
- modelsApi.list(),
176
- modelsApi.getCurrent(),
177
- modelsApi.getEffort(),
178
  settingsApi.getUser(),
179
  loadH5AccessSettings(previousH5Access),
180
  ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white'
182
  useUIStore.getState().setTheme(theme)
183
  set({
184
  permissionMode: mode,
185
- availableModels: modelsRes.models,
186
- activeProviderId: modelsRes.provider?.id ?? null,
187
- activeProviderName: modelsRes.provider?.name ?? null,
188
- currentModel: model,
189
- effortLevel: level,
190
  thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
191
  theme,
192
  skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
@@ -225,16 +264,22 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
225
  },
226
 
227
  setModel: async (modelId) => {
228
- await modelsApi.setCurrent(modelId)
229
- const { model } = await modelsApi.getCurrent()
230
- set({ currentModel: model })
 
 
 
 
 
 
231
  },
232
 
233
  setEffort: async (level) => {
234
  const prev = get().effortLevel
235
  set({ effortLevel: level })
236
  try {
237
- await modelsApi.setEffort(level)
238
  } catch {
239
  set({ effortLevel: prev })
240
  }
 
3
  import { settingsApi } from '../api/settings'
4
  import { modelsApi } from '../api/models'
5
  import { h5AccessApi } from '../api/h5Access'
6
+ import { fetchProviderModels } from '../api/providerModels'
7
+ import { getTuiConfig, saveTuiConfigPatch } from '../api/config'
8
  import {
9
  isThemeMode,
10
  type AppMode,
 
172
  set({ isLoading: true, error: null })
173
  try {
174
  const previousH5Access = get().h5Access
175
+ const [directModels, { mode }, config, userSettings, h5AccessResult] = await Promise.all([
176
+ // Fetch models directly from provider APIs (TUI-style)
177
+ fetchProviderModels().catch(() => ({ models: [] as ModelInfo[], provider: null as { id: string; name: string } | null })),
178
  settingsApi.getPermissionMode(),
179
+ // Read model + effort from ~/.claude.json (TUI-style), bypassing sidecar
180
+ getTuiConfig(),
 
181
  settingsApi.getUser(),
182
  loadH5AccessSettings(previousH5Access),
183
  ])
184
+
185
+ // Use direct models if available (TUI-style), otherwise fall back to sidecar
186
+ let availableModels: ModelInfo[]
187
+ let activeProviderId: string | null
188
+ let activeProviderName: string | null
189
+
190
+ if (directModels.models.length > 0) {
191
+ availableModels = directModels.models
192
+ activeProviderId = directModels.provider?.id ?? null
193
+ activeProviderName = directModels.provider?.name ?? null
194
+ } else {
195
+ const sidecarRes = await modelsApi.list().catch(() => null)
196
+ if (sidecarRes && sidecarRes.models.length > 0) {
197
+ availableModels = sidecarRes.models
198
+ activeProviderId = sidecarRes.provider?.id ?? null
199
+ activeProviderName = sidecarRes.provider?.name ?? null
200
+ } else {
201
+ availableModels = []
202
+ activeProviderId = null
203
+ activeProviderName = null
204
+ }
205
+ }
206
+
207
+ // Derive currentModel from config's model field, matched against availableModels
208
+ const configModelId = config.model ?? null
209
+ const currentModel: ModelInfo | null = configModelId
210
+ ? availableModels.find((m) => m.id === configModelId) ?? {
211
+ id: configModelId,
212
+ name: configModelId,
213
+ description: '',
214
+ context: '',
215
+ }
216
+ : null
217
+ const effortLevel: EffortLevel =
218
+ (config.effortLevel as EffortLevel) ?? 'medium'
219
+
220
  const theme = isThemeMode(userSettings.theme) ? userSettings.theme : 'white'
221
  useUIStore.getState().setTheme(theme)
222
  set({
223
  permissionMode: mode,
224
+ availableModels,
225
+ activeProviderId,
226
+ activeProviderName,
227
+ currentModel,
228
+ effortLevel,
229
  thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
230
  theme,
231
  skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
 
264
  },
265
 
266
  setModel: async (modelId) => {
267
+ await saveTuiConfigPatch({ model: modelId })
268
+ const availableModels = get().availableModels
269
+ const currentModel: ModelInfo | null = availableModels.find((m) => m.id === modelId) ?? {
270
+ id: modelId,
271
+ name: modelId,
272
+ description: '',
273
+ context: '',
274
+ }
275
+ set({ currentModel })
276
  },
277
 
278
  setEffort: async (level) => {
279
  const prev = get().effortLevel
280
  set({ effortLevel: level })
281
  try {
282
+ await saveTuiConfigPatch({ effortLevel: level })
283
  } catch {
284
  set({ effortLevel: prev })
285
  }
src/components/Messages.tsx CHANGED
@@ -544,7 +544,7 @@ const MessagesImpl = ({
544
  }, [collapsed_0, renderRange, virtualScrollRuntimeGate, disableRenderCap]);
545
  const streamingToolUseIDs = useMemo(() => new Set(streamingToolUses.map(__0 => __0.contentBlock.id)), [streamingToolUses]);
546
 
547
- useAutoTTS(renderableMessages);
548
 
549
  // Divider insertion point: first renderableMessage whose uuid shares the
550
  // 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
 
544
  }, [collapsed_0, renderRange, virtualScrollRuntimeGate, disableRenderCap]);
545
  const streamingToolUseIDs = useMemo(() => new Set(streamingToolUses.map(__0 => __0.contentBlock.id)), [streamingToolUses]);
546
 
547
+ useAutoTTS(renderableMessages, isLoading);
548
 
549
  // Divider insertion point: first renderableMessage whose uuid shares the
550
  // 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
src/hooks/useAutoTTS.ts CHANGED
@@ -24,27 +24,47 @@ function resolveEdgeTTSVoice(language: string | undefined, explicitVoice: string
24
  return 'en-US-JennyNeural'
25
  }
26
 
27
- export function useAutoTTS(messages: RenderableMessage[]): void {
28
  const triggeredIdsRef = useRef<Set<string>>(new Set())
29
  const pendingPlayRef = useRef<Promise<void> | null>(null)
30
 
 
 
 
 
 
 
 
31
  useEffect(() => {
32
  const settings = getInitialSettings()
33
  if (!settings.voiceAutoTTS || !settings.voiceEnabled) return
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  for (const msg of messages) {
36
- if (msg.type !== 'assistant') continue
37
  if (triggeredIdsRef.current.has(msg.uuid)) continue
 
 
38
 
 
 
 
39
  const content = msg.message.content[0]
40
  if (content?.type !== 'text') continue
41
  if (!content.text.trim()) continue
42
 
43
- triggeredIdsRef.current.add(msg.uuid)
44
-
45
  const text = content.text
46
- const language = settings.voiceLanguage || settings.language
47
- const voice = resolveEdgeTTSVoice(language, settings.voiceTTSVoice)
48
 
49
  const run = async () => {
50
  const result = await speakWithEdgeTTS(text, {
@@ -62,5 +82,5 @@ export function useAutoTTS(messages: RenderableMessage[]): void {
62
  pendingPlayRef.current = run().catch(() => {})
63
  }
64
  }
65
- }, [messages])
66
  }
 
24
  return 'en-US-JennyNeural'
25
  }
26
 
27
+ export function useAutoTTS(messages: RenderableMessage[], isLoading?: boolean): void {
28
  const triggeredIdsRef = useRef<Set<string>>(new Set())
29
  const pendingPlayRef = useRef<Promise<void> | null>(null)
30
 
31
+ // Snapshot of message IDs present when the conversation first becomes "active".
32
+ // isLoading goes true on the first user submit and stays false on resume.
33
+ // By waiting for isLoading=true we ensure the snapshot captures the correct
34
+ // boundary: everything before it is history (skip), everything after is new.
35
+ const historyIdsRef = useRef<Set<string> | null>(null)
36
+ const wasLoadingRef = useRef<boolean>(false)
37
+
38
  useEffect(() => {
39
  const settings = getInitialSettings()
40
  if (!settings.voiceAutoTTS || !settings.voiceEnabled) return
41
 
42
+ // Capture history snapshot on the transition from idle → active.
43
+ // This fires once: when the user first submits a message in a fresh REPL,
44
+ // or (importantly) never during a resume where isLoading stays false.
45
+ if (!historyIdsRef.current && !wasLoadingRef.current && isLoading) {
46
+ historyIdsRef.current = new Set(messages.map(m => m.uuid))
47
+ }
48
+ wasLoadingRef.current = isLoading ?? false
49
+
50
+ const voice = resolveEdgeTTSVoice(
51
+ settings.voiceLanguage || settings.language,
52
+ settings.voiceTTSVoice,
53
+ )
54
+
55
  for (const msg of messages) {
 
56
  if (triggeredIdsRef.current.has(msg.uuid)) continue
57
+ // Skip messages that were present before the conversation became active.
58
+ if (historyIdsRef.current?.has(msg.uuid)) continue
59
 
60
+ triggeredIdsRef.current.add(msg.uuid)
61
+
62
+ if (msg.type !== 'assistant') continue
63
  const content = msg.message.content[0]
64
  if (content?.type !== 'text') continue
65
  if (!content.text.trim()) continue
66
 
 
 
67
  const text = content.text
 
 
68
 
69
  const run = async () => {
70
  const result = await speakWithEdgeTTS(text, {
 
82
  pendingPlayRef.current = run().catch(() => {})
83
  }
84
  }
85
+ }, [messages, isLoading])
86
  }