sigit-code 1.7.0__tar.gz → 1.7.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {sigit_code-1.7.0 → sigit_code-1.7.2}/Cargo.lock +2 -2
  2. {sigit_code-1.7.0 → sigit_code-1.7.2}/Cargo.toml +2 -2
  3. {sigit_code-1.7.0 → sigit_code-1.7.2}/PKG-INFO +1 -1
  4. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/Cargo.toml +1 -1
  5. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/README.md +2 -0
  6. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/chat.rs +145 -9
  7. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/headless.rs +8 -0
  8. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/inline_tool_calls.rs +307 -36
  9. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/main.rs +84 -27
  10. sigit_code-1.7.2/crates/cli/src/program_status.rs +149 -0
  11. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/setup.rs +117 -102
  12. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/tools.rs +119 -66
  13. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/headless_mode.rs +72 -0
  14. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/sigit-agent/Cargo.toml +1 -0
  15. sigit_code-1.7.2/crates/sigit-agent/README.md +70 -0
  16. {sigit_code-1.7.0 → sigit_code-1.7.2}/README.md +0 -0
  17. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/account.rs +0 -0
  18. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/backend.rs +0 -0
  19. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/client_fs.rs +0 -0
  20. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/client_terminal.rs +0 -0
  21. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/commands.rs +0 -0
  22. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/frontmatter.rs +0 -0
  23. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/harness_markup.rs +0 -0
  24. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/hooks.rs +0 -0
  25. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/instructions.rs +0 -0
  26. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/mcp.rs +0 -0
  27. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/models.rs +0 -0
  28. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/permissions.rs +0 -0
  29. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/provider.rs +0 -0
  30. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/repetition.rs +0 -0
  31. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/session_store.rs +0 -0
  32. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/settings.rs +0 -0
  33. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/skills.rs +0 -0
  34. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/subagents.rs +0 -0
  35. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/src/workspace.rs +0 -0
  36. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_audio.rs +0 -0
  37. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_client_fs.rs +0 -0
  38. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_client_terminal.rs +0 -0
  39. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_concurrent_sessions.rs +0 -0
  40. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_elicitation.rs +0 -0
  41. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_embedded_context.rs +0 -0
  42. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_endpoint_errors.rs +0 -0
  43. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_error_codes.rs +0 -0
  44. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_harness_markup.rs +0 -0
  45. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_images.rs +0 -0
  46. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_logout.rs +0 -0
  47. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_multi_root.rs +0 -0
  48. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_permissions.rs +0 -0
  49. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_session_list.rs +0 -0
  50. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_session_load.rs +0 -0
  51. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_stop_reasons.rs +0 -0
  52. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_tool_call_name.rs +0 -0
  53. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/acp_tool_stdin.rs +0 -0
  54. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/cli/tests/version_flag.rs +0 -0
  55. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/sigit-agent/src/auth.rs +0 -0
  56. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/sigit-agent/src/browser_auth.rs +0 -0
  57. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/sigit-agent/src/credentials.rs +0 -0
  58. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/sigit-agent/src/lib.rs +0 -0
  59. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/sigit-agent/src/profile.rs +0 -0
  60. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/sigit-agent/src/prompt.rs +0 -0
  61. {sigit_code-1.7.0 → sigit_code-1.7.2}/crates/sigit-agent/tests/embedded.rs +0 -0
  62. {sigit_code-1.7.0 → sigit_code-1.7.2}/pyproject.toml +0 -0
@@ -5945,7 +5945,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5945
5945
 
5946
5946
  [[package]]
5947
5947
  name = "sigit"
5948
- version = "1.7.0"
5948
+ version = "1.7.2"
5949
5949
  dependencies = [
5950
5950
  "agent-client-protocol",
5951
5951
  "anyhow",
@@ -5976,7 +5976,7 @@ dependencies = [
5976
5976
 
5977
5977
  [[package]]
5978
5978
  name = "sigit-agent"
5979
- version = "1.7.0"
5979
+ version = "1.7.2"
5980
5980
  dependencies = [
5981
5981
  "agent-client-protocol",
5982
5982
  "anyhow",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/cli", "crates/sigit-*"]
4
4
 
5
5
  [workspace.package]
6
- version = "1.7.0"
6
+ version = "1.7.2"
7
7
  edition = "2024"
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/getsigit/sigit"
@@ -16,7 +16,7 @@ authors = ["Seto Elkahfi <seto@sigit.si>", "paydii <193906237+paydii@users.norep
16
16
  # crates.io, so this resolves from the registry rather than the onde-ed checkout next door.
17
17
  ed-acp = { version = "1.3.0", default-features = false }
18
18
  ed-acp-testkit = "1.3.0"
19
- sigit-agent = { version = "1.7.0", default-features = false, path = "crates/sigit-agent" }
19
+ sigit-agent = { version = "1.7.2", default-features = false, path = "crates/sigit-agent" }
20
20
 
21
21
  # ACP protocol SDK
22
22
  agent-client-protocol = { version = "2.2", features = ["unstable_session_fork"] }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sigit-code
3
- Version: 1.7.0
3
+ Version: 1.7.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -8,7 +8,7 @@ license.workspace = true
8
8
  repository.workspace = true
9
9
  homepage.workspace = true
10
10
  readme = "README.md"
11
- keywords = ["cli", "ai", "coding-agent", "llm", "acp", "local", "on-device", "sigit"]
11
+ keywords = ["cli", "ai", "coding-agent", "llm", "acp"]
12
12
  categories = ["command-line-utilities"]
13
13
  authors.workspace = true
14
14
  # `src/bin/` holds test-only helper binaries (the MCP stdio stub used by the
@@ -157,6 +157,8 @@ Run `sigit` in a terminal and you get the same model and system prompt as the ed
157
157
 
158
158
  Terminal mode currently needs Unix terminal behavior, so it works on macOS and Linux only.
159
159
 
160
+ The interactive UI emits [OSC 7501 program status](https://www.superlogical.com/rex/docs/build/program-status) reports so supporting terminals can show working, waiting for permission or input, browser sign-in, completion, and errors across tabs. User-driven quit reports idle; terminals expire active work on process exit and retain completion or error reports. Status sequences are never written to headless/JSON or ACP output.
161
+
160
162
  ## Headless and CI mode
161
163
 
162
164
  Use `sigit run` to execute the same agent from scripts, CI, or another siGit Factory client:
@@ -519,6 +519,7 @@ mod tui {
519
519
  use crate::models::{
520
520
  InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items,
521
521
  };
522
+ use crate::program_status::{Reporter, Status};
522
523
  use crate::session_store::SessionEntry;
523
524
  use ratatui::{
524
525
  Frame,
@@ -731,6 +732,8 @@ mod tui {
731
732
  // ── App state ─────────────────────────────────────────────────────────────
732
733
 
733
734
  struct App {
735
+ program_status: Reporter,
736
+ outcome: Status,
734
737
  messages: Vec<ChatMessage>,
735
738
  input: String,
736
739
  cursor: usize,
@@ -855,7 +858,11 @@ mod tui {
855
858
  }
856
859
 
857
860
  impl App {
858
- fn new(load_model_name: String, backend: Arc<dyn InferenceBackend>) -> Self {
861
+ fn new(
862
+ load_model_name: String,
863
+ backend: Arc<dyn InferenceBackend>,
864
+ program_status: Reporter,
865
+ ) -> Self {
859
866
  let is_remote = backend.is_remote();
860
867
  let items = build_model_picker_items();
861
868
  let tool_calling = items
@@ -890,6 +897,8 @@ mod tui {
890
897
  model_load_cancelled: false,
891
898
  switching_model_id: None,
892
899
  download_progress: None,
900
+ program_status,
901
+ outcome: Status::Idle,
893
902
  is_loading: true,
894
903
  load_tick: 0,
895
904
  load_error: None,
@@ -959,6 +968,34 @@ mod tui {
959
968
  self.history_sessions.get(self.history_index)
960
969
  }
961
970
 
971
+ fn finish_login(&mut self, result: Result<String, String>) {
972
+ self.outcome = if result.is_ok() {
973
+ Status::Done
974
+ } else {
975
+ Status::Error
976
+ };
977
+ self.program_status.report(self.outcome);
978
+ self.messages.push(ChatMessage::system(
979
+ result.unwrap_or_else(|message| message),
980
+ ));
981
+ }
982
+
983
+ fn status(&self) -> Status {
984
+ if self.quit {
985
+ Status::Idle
986
+ } else if self.load_error.is_some() {
987
+ Status::Error
988
+ } else if self.pending_approval.is_some() {
989
+ Status::Permission
990
+ } else if self.is_loading || self.is_busy() || self.inference_rx.is_some() {
991
+ Status::Working
992
+ } else if self.show_model_picker || self.history_pending_delete.is_some() {
993
+ Status::Question
994
+ } else {
995
+ self.outcome
996
+ }
997
+ }
998
+
962
999
  fn is_busy(&self) -> bool {
963
1000
  self.is_streaming() || self.thinking || self.switching_model
964
1001
  }
@@ -1000,11 +1037,13 @@ mod tui {
1000
1037
  }
1001
1038
 
1002
1039
  fn start_thinking(&mut self) {
1040
+ self.outcome = Status::Idle;
1003
1041
  self.thinking = true;
1004
1042
  self.thinking_tick = 0;
1005
1043
  }
1006
1044
 
1007
1045
  fn stop_thinking(&mut self) {
1046
+ self.outcome = Status::Idle;
1008
1047
  self.thinking = false;
1009
1048
  self.inference_rx = None;
1010
1049
  // Dropping a pending reply channel reads as a denial on the
@@ -1634,6 +1673,10 @@ mod tui {
1634
1673
  };
1635
1674
  if app.history_pending_delete.as_deref() == Some(id.as_str()) {
1636
1675
  crate::session_store::delete(&id);
1676
+ // A plan must not outlive its conversation. The id is
1677
+ // the live session's key when the user deletes the
1678
+ // thread the TUI is currently running.
1679
+ crate::tools::clear_todos(&id);
1637
1680
  app.refresh_history();
1638
1681
  app.history_notice = Some(format!("Deleted session '{id}'."));
1639
1682
  } else {
@@ -2837,6 +2880,8 @@ mod tui {
2837
2880
  crate::permissions::reset_session(crate::permissions::TUI_SESSION);
2838
2881
  // The saved session must not resurrect what the user just wiped.
2839
2882
  crate::session_store::delete(TUI_STORE_SESSION);
2883
+ // The plan belongs to the conversation, so the wipe drops it too.
2884
+ crate::tools::clear_todos(crate::permissions::TUI_SESSION);
2840
2885
  app.context_tokens_used = None;
2841
2886
  app.messages.push(ChatMessage::system(format!(
2842
2887
  "Cleared {cleared} turn(s). History is empty.",
@@ -2984,6 +3029,7 @@ mod tui {
2984
3029
  )));
2985
3030
  }
2986
3031
  None => {
3032
+ app.outcome = Status::Auth;
2987
3033
  app.messages.push(ChatMessage::system(
2988
3034
  "siGit Code Cloud needs an account. Use \
2989
3035
  `/login <email> <password>`, or create one at sigit.si.",
@@ -3035,13 +3081,22 @@ mod tui {
3035
3081
  }
3036
3082
  },
3037
3083
  SlashCommand::Login(arg) => {
3038
- let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
3084
+ let password_login = arg
3085
+ .as_deref()
3086
+ .and_then(crate::account::parse_login_args)
3087
+ .is_some();
3088
+ app.program_status.report(if password_login {
3089
+ Status::Working
3090
+ } else {
3091
+ Status::Auth
3092
+ });
3093
+ let result = match arg.as_deref().and_then(crate::account::parse_login_args) {
3039
3094
  Some((email, password)) => {
3040
3095
  match crate::account::authenticate(&email, &password).await {
3041
- Ok(email) => format!(
3096
+ Ok(email) => Ok(format!(
3042
3097
  "Signed in as {email}. siGit Code Cloud applies to your next session."
3043
- ),
3044
- Err(error) => format!("Login failed: {error}"),
3098
+ )),
3099
+ Err(error) => Err(format!("Login failed: {error}")),
3045
3100
  }
3046
3101
  }
3047
3102
  // A bare `/login` opens the browser instead of asking for
@@ -3053,7 +3108,7 @@ mod tui {
3053
3108
  crate::browser_login_message().await
3054
3109
  }
3055
3110
  };
3056
- app.messages.push(ChatMessage::system(message));
3111
+ app.finish_login(result);
3057
3112
  }
3058
3113
  SlashCommand::Logout => {
3059
3114
  let message = crate::account::end_session().await;
@@ -3430,13 +3485,22 @@ mod tui {
3430
3485
  backend: Arc<dyn InferenceBackend>,
3431
3486
  load_rx: std_mpsc::Receiver<Result<(), String>>,
3432
3487
  load_model_name: String,
3488
+ program_status: Reporter,
3433
3489
  ) -> Result<()>
3434
3490
  where
3435
3491
  // ratatui 0.30 gives each backend its own error type; `?` into anyhow needs it
3436
3492
  // to be thread-safe.
3437
3493
  B::Error: Send + Sync + 'static,
3438
3494
  {
3439
- event_loop(terminal, engine, backend, load_rx, load_model_name).await
3495
+ event_loop(
3496
+ terminal,
3497
+ engine,
3498
+ backend,
3499
+ load_rx,
3500
+ load_model_name,
3501
+ program_status,
3502
+ )
3503
+ .await
3440
3504
  }
3441
3505
 
3442
3506
  async fn event_loop<B: ratatui::backend::Backend>(
@@ -3445,13 +3509,14 @@ mod tui {
3445
3509
  backend: Arc<dyn InferenceBackend>,
3446
3510
  load_rx: std_mpsc::Receiver<Result<(), String>>,
3447
3511
  load_model_name: String,
3512
+ program_status: Reporter,
3448
3513
  ) -> Result<()>
3449
3514
  where
3450
3515
  // ratatui 0.30 gives each backend its own error type; `?` into anyhow needs it
3451
3516
  // to be thread-safe.
3452
3517
  B::Error: Send + Sync + 'static,
3453
3518
  {
3454
- let mut app = App::new(load_model_name, backend);
3519
+ let mut app = App::new(load_model_name, backend, program_status);
3455
3520
  let mut event_stream = EventStream::new();
3456
3521
 
3457
3522
  // 10 fps is plenty for spinners
@@ -3476,6 +3541,7 @@ mod tui {
3476
3541
  if let Some(rx) = app.model_load_rx.as_mut() {
3477
3542
  match rx.try_recv() {
3478
3543
  Ok(ModelLoadUpdate::Loaded(model_name)) => {
3544
+ app.outcome = Status::Done;
3479
3545
  engine.clear_history().await;
3480
3546
  app.context_tokens_used = None;
3481
3547
  if let Some(tc) = app.pending_tool_calling.take() {
@@ -3521,6 +3587,7 @@ mod tui {
3521
3587
  }
3522
3588
  }
3523
3589
  Ok(ModelLoadUpdate::Error(error)) => {
3590
+ app.outcome = Status::Error;
3524
3591
  app.switching_model = false;
3525
3592
  app.switching_model_id = None;
3526
3593
  app.download_progress = None;
@@ -3538,6 +3605,7 @@ mod tui {
3538
3605
  app.model_load_cancelled = false;
3539
3606
  app.model_load_rx = None;
3540
3607
  if !was_cancelled {
3608
+ app.outcome = Status::Error;
3541
3609
  app.messages.push(ChatMessage::system(
3542
3610
  "error loading model: loader task disconnected".to_string(),
3543
3611
  ));
@@ -3546,6 +3614,10 @@ mod tui {
3546
3614
  }
3547
3615
  }
3548
3616
 
3617
+ // Polling model loads can change state immediately before select
3618
+ // goes idle, so report after applying those updates.
3619
+ app.program_status.report(app.status());
3620
+
3549
3621
  if app.quit {
3550
3622
  break;
3551
3623
  }
@@ -3593,6 +3665,7 @@ mod tui {
3593
3665
  }
3594
3666
  Some(InferenceUpdate::Response(text)) => {
3595
3667
  app.stop_thinking();
3668
+ app.outcome = Status::Done;
3596
3669
  app.messages.push(ChatMessage::assistant(text));
3597
3670
  }
3598
3671
  Some(InferenceUpdate::ContextUsage(tokens)) => {
@@ -3601,6 +3674,7 @@ mod tui {
3601
3674
  Some(InferenceUpdate::Error(msg)) => {
3602
3675
  app.finalize_stream();
3603
3676
  app.stop_thinking();
3677
+ app.outcome = Status::Error;
3604
3678
  app.messages.push(ChatMessage::system(format!("error: {msg}")));
3605
3679
  }
3606
3680
  Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
@@ -3621,6 +3695,7 @@ mod tui {
3621
3695
  // task finished, possibly with no text to show
3622
3696
  app.finalize_stream();
3623
3697
  app.stop_thinking();
3698
+ app.outcome = Status::Done;
3624
3699
  }
3625
3700
  }
3626
3701
  }
@@ -3703,6 +3778,9 @@ mod tui {
3703
3778
  };
3704
3779
 
3705
3780
  if let Event::Key(key) = event {
3781
+ if key.kind == KeyEventKind::Press {
3782
+ app.outcome = Status::Idle;
3783
+ }
3706
3784
  // loading phase — only quit keys work
3707
3785
  if app.is_loading {
3708
3786
  if key.kind == KeyEventKind::Press {
@@ -3810,6 +3888,10 @@ mod tui {
3810
3888
  app.stop_thinking();
3811
3889
  app.messages.push(ChatMessage::system("(cancelled)"));
3812
3890
  }
3891
+ // A visible stream can have cleared `thinking`;
3892
+ // cancellation must drop its receiver and clear
3893
+ // thinking so the tab cannot stay on working.
3894
+ app.stop_thinking();
3813
3895
  if app.switching_model {
3814
3896
  // flag before drop so Disconnected handler stays quiet
3815
3897
  app.model_load_cancelled = true;
@@ -3947,8 +4029,62 @@ mod tui {
3947
4029
  }
3948
4030
  #[cfg(test)]
3949
4031
  mod tui_tests {
3950
- use super::{compact_tokens, context_gauge_color, context_gauge_label};
4032
+ use super::{
4033
+ App, OpenAiBackend, Status, compact_tokens, context_gauge_color, context_gauge_label,
4034
+ };
3951
4035
  use ratatui::style::Color;
4036
+ use std::sync::Arc;
4037
+
4038
+ #[test]
4039
+ fn login_status_uses_the_result_instead_of_message_wording() {
4040
+ let backend = Arc::new(OpenAiBackend::new("http://localhost/v1", "", "test", None));
4041
+ let mut app = App::new(
4042
+ "test".into(),
4043
+ backend,
4044
+ super::Reporter::new(std::io::sink()),
4045
+ );
4046
+ app.finish_loading();
4047
+ app.finish_login(Ok("Welcome back.".into()));
4048
+ assert_eq!(app.status(), Status::Done);
4049
+ app.finish_login(Err("Signed in could not be completed.".into()));
4050
+ assert_eq!(app.status(), Status::Error);
4051
+ }
4052
+
4053
+ #[test]
4054
+ fn program_status_tracks_loading_approval_completion_and_cancellation() {
4055
+ let backend = Arc::new(OpenAiBackend::new("http://localhost/v1", "", "test", None));
4056
+ let mut app = App::new(
4057
+ "test".into(),
4058
+ backend,
4059
+ super::Reporter::new(std::io::sink()),
4060
+ );
4061
+ assert_eq!(app.status(), Status::Working);
4062
+ app.finish_loading();
4063
+ assert_eq!(app.status(), Status::Idle);
4064
+ app.start_thinking();
4065
+ assert_eq!(app.status(), Status::Working);
4066
+ let (reply, _) = tokio::sync::oneshot::channel();
4067
+ app.pending_approval = Some(("edit_file".into(), reply));
4068
+ assert_eq!(app.status(), Status::Permission);
4069
+ app.pending_approval = None;
4070
+ assert_eq!(app.status(), Status::Working);
4071
+ app.stop_thinking();
4072
+ assert_eq!(app.status(), Status::Idle);
4073
+ app.outcome = Status::Done;
4074
+ assert_eq!(app.status(), Status::Done);
4075
+ app.outcome = Status::Error;
4076
+ assert_eq!(app.status(), Status::Error);
4077
+ app.start_thinking();
4078
+ assert_eq!(app.status(), Status::Working);
4079
+ app.stop_thinking();
4080
+ app.show_model_picker = true;
4081
+ assert_eq!(app.status(), Status::Question);
4082
+ app.show_model_picker = false;
4083
+ app.set_load_error("failed".into());
4084
+ assert_eq!(app.status(), Status::Error);
4085
+ app.quit = true;
4086
+ assert_eq!(app.status(), Status::Idle);
4087
+ }
3952
4088
 
3953
4089
  #[test]
3954
4090
  fn context_gauge_reports_share_of_the_window() {
@@ -526,6 +526,14 @@ async fn run_prompt(
526
526
 
527
527
  let allow_tool_calls = round < config.max_tool_rounds;
528
528
 
529
+ // The round that reaches the cap is forced to answer in text. Say so,
530
+ // or the model spends its last reply narrating the step it can no
531
+ // longer take.
532
+ if !allow_tool_calls && let Some(last) = tool_results.last_mut() {
533
+ last.content
534
+ .push_str(&crate::headless_round_cap_note(config.max_tool_rounds));
535
+ }
536
+
529
537
  // Whatever this round says starts a new paragraph rather than
530
538
  // continuing the sentence the tool calls interrupted.
531
539
  reply.interrupt();