@sigitcode-old / sigit / commits / 3fc7b35
Fork 1

I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

feat: report interactive terminal program status

Max Farrell committed Oct 12, 2026 at 08:41 UTC 3fc7b35dd3c56daf7ae2d029f07364ca39a3976f
4 files changed +256 -2
README.md
+2
@@ -136,6 +136,8 @@ Run `sigit` in a terminal and you get the same model and system prompt as the ed
136
137 Terminal mode currently needs Unix terminal behavior, so it works on macOS and Linux only.
138
139 +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. Reports are cleared on exit and are never written to headless/JSON or ACP output.
140 +
141 ## Headless and CI mode
142
143 Use `sigit run` to execute the same agent from scripts, CI, or another siGit Factory client:
src/chat.rs
+99 -2
@@ -519,6 +519,7 @@ mod tui {
519 use crate::models::{
520 InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items,
521 };
522 + use crate::program_status::{Reporter, Status};
523 use crate::session_store::SessionEntry;
524 use ratatui::{
525 Frame,
@@ -731,6 +732,8 @@ mod tui {
732 // ── App state ─────────────────────────────────────────────────────────────
733
734 struct App {
735 + program_status: Reporter,
736 + outcome: Status,
737 messages: Vec<ChatMessage>,
738 input: String,
739 cursor: usize,
@@ -890,6 +893,8 @@ mod tui {
893 model_load_cancelled: false,
894 switching_model_id: None,
895 download_progress: None,
896 + program_status: Reporter::new(std::io::sink()),
897 + outcome: Status::Idle,
898 is_loading: true,
899 load_tick: 0,
900 load_error: None,
@@ -959,6 +964,20 @@ mod tui {
964 self.history_sessions.get(self.history_index)
965 }
966
967 + fn status(&self) -> Status {
968 + if self.load_error.is_some() {
969 + Status::Error
970 + } else if self.pending_approval.is_some() {
971 + Status::Permission
972 + } else if self.is_loading || self.is_busy() || self.inference_rx.is_some() {
973 + Status::Working
974 + } else if self.show_model_picker || self.history_pending_delete.is_some() {
975 + Status::Question
976 + } else {
977 + self.outcome
978 + }
979 + }
980 +
981 fn is_busy(&self) -> bool {
982 self.is_streaming() || self.thinking || self.switching_model
983 }
@@ -1000,11 +1019,13 @@ mod tui {
1019 }
1020
1021 fn start_thinking(&mut self) {
1022 + self.outcome = Status::Idle;
1023 self.thinking = true;
1024 self.thinking_tick = 0;
1025 }
1026
1027 fn stop_thinking(&mut self) {
1028 + self.outcome = Status::Idle;
1029 self.thinking = false;
1030 self.inference_rx = None;
1031 // Dropping a pending reply channel reads as a denial on the
@@ -2984,6 +3005,7 @@ mod tui {
3005 )));
3006 }
3007 None => {
3008 + app.outcome = Status::Auth;
3009 app.messages.push(ChatMessage::system(
3010 "siGit Code Cloud needs an account. Use \
3011 `/login <email> <password>`, or create one at sigit.si.",
@@ -3035,6 +3057,15 @@ mod tui {
3057 }
3058 },
3059 SlashCommand::Login(arg) => {
3060 + let password_login = arg
3061 + .as_deref()
3062 + .and_then(crate::account::parse_login_args)
3063 + .is_some();
3064 + app.program_status.report(if password_login {
3065 + Status::Working
3066 + } else {
3067 + Status::Auth
3068 + });
3069 let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
3070 Some((email, password)) => {
3071 match crate::account::authenticate(&email, &password).await {
@@ -3053,6 +3084,12 @@ mod tui {
3084 crate::browser_login_message().await
3085 }
3086 };
3087 + app.outcome = if message.starts_with("Signed in") {
3088 + Status::Done
3089 + } else {
3090 + Status::Error
3091 + };
3092 + app.program_status.report(app.outcome);
3093 app.messages.push(ChatMessage::system(message));
3094 }
3095 SlashCommand::Logout => {
@@ -3430,13 +3467,22 @@ mod tui {
3467 backend: Arc<dyn InferenceBackend>,
3468 load_rx: std_mpsc::Receiver<Result<(), String>>,
3469 load_model_name: String,
3470 + program_status: Reporter,
3471 ) -> Result<()>
3472 where
3473 // ratatui 0.30 gives each backend its own error type; `?` into anyhow needs it
3474 // to be thread-safe.
3475 B::Error: Send + Sync + 'static,
3476 {
3439 - event_loop(terminal, engine, backend, load_rx, load_model_name).await
3477 + event_loop(
3478 + terminal,
3479 + engine,
3480 + backend,
3481 + load_rx,
3482 + load_model_name,
3483 + program_status,
3484 + )
3485 + .await
3486 }
3487
3488 async fn event_loop<B: ratatui::backend::Backend>(
@@ -3445,6 +3491,7 @@ mod tui {
3491 backend: Arc<dyn InferenceBackend>,
3492 load_rx: std_mpsc::Receiver<Result<(), String>>,
3493 load_model_name: String,
3494 + program_status: Reporter,
3495 ) -> Result<()>
3496 where
3497 // ratatui 0.30 gives each backend its own error type; `?` into anyhow needs it
@@ -3452,6 +3499,7 @@ mod tui {
3499 B::Error: Send + Sync + 'static,
3500 {
3501 let mut app = App::new(load_model_name, backend);
3502 + app.program_status = program_status;
3503 let mut event_stream = EventStream::new();
3504
3505 // 10 fps is plenty for spinners
@@ -3476,6 +3524,7 @@ mod tui {
3524 if let Some(rx) = app.model_load_rx.as_mut() {
3525 match rx.try_recv() {
3526 Ok(ModelLoadUpdate::Loaded(model_name)) => {
3527 + app.outcome = Status::Done;
3528 engine.clear_history().await;
3529 app.context_tokens_used = None;
3530 if let Some(tc) = app.pending_tool_calling.take() {
@@ -3521,6 +3570,7 @@ mod tui {
3570 }
3571 }
3572 Ok(ModelLoadUpdate::Error(error)) => {
3573 + app.outcome = Status::Error;
3574 app.switching_model = false;
3575 app.switching_model_id = None;
3576 app.download_progress = None;
@@ -3538,6 +3588,7 @@ mod tui {
3588 app.model_load_cancelled = false;
3589 app.model_load_rx = None;
3590 if !was_cancelled {
3591 + app.outcome = Status::Error;
3592 app.messages.push(ChatMessage::system(
3593 "error loading model: loader task disconnected".to_string(),
3594 ));
@@ -3546,6 +3597,10 @@ mod tui {
3597 }
3598 }
3599
3600 + // Polling model loads can change state immediately before select
3601 + // goes idle, so report after applying those updates.
3602 + app.program_status.report(app.status());
3603 +
3604 if app.quit {
3605 break;
3606 }
@@ -3593,6 +3648,7 @@ mod tui {
3648 }
3649 Some(InferenceUpdate::Response(text)) => {
3650 app.stop_thinking();
3651 + app.outcome = Status::Done;
3652 app.messages.push(ChatMessage::assistant(text));
3653 }
3654 Some(InferenceUpdate::ContextUsage(tokens)) => {
@@ -3601,6 +3657,7 @@ mod tui {
3657 Some(InferenceUpdate::Error(msg)) => {
3658 app.finalize_stream();
3659 app.stop_thinking();
3660 + app.outcome = Status::Error;
3661 app.messages.push(ChatMessage::system(format!("error: {msg}")));
3662 }
3663 Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
@@ -3621,6 +3678,7 @@ mod tui {
3678 // task finished, possibly with no text to show
3679 app.finalize_stream();
3680 app.stop_thinking();
3681 + app.outcome = Status::Done;
3682 }
3683 }
3684 }
@@ -3703,6 +3761,9 @@ mod tui {
3761 };
3762
3763 if let Event::Key(key) = event {
3764 + if key.kind == KeyEventKind::Press {
3765 + app.outcome = Status::Idle;
3766 + }
3767 // loading phase — only quit keys work
3768 if app.is_loading {
3769 if key.kind == KeyEventKind::Press {
@@ -3810,6 +3871,9 @@ mod tui {
3871 app.stop_thinking();
3872 app.messages.push(ChatMessage::system("(cancelled)"));
3873 }
3874 + // A visible stream can have cleared `thinking`;
3875 + // cancellation must still drop its receiver.
3876 + app.stop_thinking();
3877 if app.switching_model {
3878 // flag before drop so Disconnected handler stays quiet
3879 app.model_load_cancelled = true;
@@ -3947,8 +4011,41 @@ mod tui {
4011 }
4012 #[cfg(test)]
4013 mod tui_tests {
3950 - use super::{compact_tokens, context_gauge_color, context_gauge_label};
4014 + use super::{
4015 + App, OpenAiBackend, Status, compact_tokens, context_gauge_color, context_gauge_label,
4016 + };
4017 use ratatui::style::Color;
4018 + use std::sync::Arc;
4019 +
4020 + #[test]
4021 + fn program_status_tracks_loading_approval_completion_and_cancellation() {
4022 + let backend = Arc::new(OpenAiBackend::new("http://localhost/v1", "", "test", None));
4023 + let mut app = App::new("test".into(), backend);
4024 + assert_eq!(app.status(), Status::Working);
4025 + app.finish_loading();
4026 + assert_eq!(app.status(), Status::Idle);
4027 + app.start_thinking();
4028 + assert_eq!(app.status(), Status::Working);
4029 + let (reply, _) = tokio::sync::oneshot::channel();
4030 + app.pending_approval = Some(("edit_file".into(), reply));
4031 + assert_eq!(app.status(), Status::Permission);
4032 + app.pending_approval = None;
4033 + assert_eq!(app.status(), Status::Working);
4034 + app.stop_thinking();
4035 + assert_eq!(app.status(), Status::Idle);
4036 + app.outcome = Status::Done;
4037 + assert_eq!(app.status(), Status::Done);
4038 + app.outcome = Status::Error;
4039 + assert_eq!(app.status(), Status::Error);
4040 + app.start_thinking();
4041 + assert_eq!(app.status(), Status::Working);
4042 + app.stop_thinking();
4043 + app.show_model_picker = true;
4044 + assert_eq!(app.status(), Status::Question);
4045 + app.show_model_picker = false;
4046 + app.set_load_error("failed".into());
4047 + assert_eq!(app.status(), Status::Error);
4048 + }
4049
4050 #[test]
4051 fn context_gauge_reports_share_of_the_window() {
src/main.rs
+9
@@ -45,6 +45,8 @@ mod instructions;
45 mod mcp;
46 mod models;
47 mod permissions;
48 +#[cfg(unix)]
49 +mod program_status;
50 mod provider;
51 mod repetition;
52 mod session_store;
@@ -6025,6 +6027,12 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
6027 }
6028 };
6029
6030 + // The saved output fd may be redirected even when stdin is a TTY.
6031 + let program_status = if std::io::IsTerminal::is_terminal(&tty) {
6032 + program_status::Reporter::new(tty.try_clone()?)
6033 + } else {
6034 + program_status::Reporter::new(std::io::sink())
6035 + };
6036 crossterm::terminal::enable_raw_mode()?;
6037 let mut tty = BufWriter::new(tty);
6038 crossterm::execute!(tty, crossterm::terminal::EnterAlternateScreen)?;
@@ -6038,6 +6046,7 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
6046 inference_backend,
6047 load_rx,
6048 startup_model_name,
6049 + program_status,
6050 )
6051 .await;
6052
src/program_status.rs new
+146
@@ -0,0 +1,146 @@
1 +//! OSC 7501 reports, owned exclusively by the interactive terminal surface.
2 +use std::io::Write;
3 +
4 +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5 +pub(crate) enum Status {
6 + Idle,
7 + Working,
8 + Done,
9 + Error,
10 + Permission,
11 + Question,
12 + Auth,
13 +}
14 +
15 +impl Status {
16 + fn pairs(self) -> &'static str {
17 + match self {
18 + Self::Idle => "state=idle",
19 + Self::Working => "state=working",
20 + Self::Done => "state=done",
21 + Self::Error => "state=error",
22 + Self::Permission => "state=blocked:kind=permission",
23 + Self::Question => "state=blocked:kind=question",
24 + Self::Auth => "state=blocked:kind=auth",
25 + }
26 + }
27 +}
28 +
29 +pub(crate) struct Reporter {
30 + writer: Box<dyn Write + Send>,
31 + last: Option<Status>,
32 +}
33 +
34 +impl Reporter {
35 + pub(crate) fn new(writer: impl Write + Send + 'static) -> Self {
36 + Self {
37 + writer: Box::new(writer),
38 + last: None,
39 + }
40 + }
41 +
42 + pub(crate) fn report(&mut self, status: Status) {
43 + if self.last == Some(status) {
44 + return;
45 + }
46 + // Status is advisory: a terminal write failure must not stop the agent.
47 + if write!(self.writer, "\x1b]7501;{}:app=sigit\x1b\\", status.pairs())
48 + .and_then(|()| self.writer.flush())
49 + .is_ok()
50 + {
51 + self.last = Some(status);
52 + }
53 + }
54 +}
55 +
56 +impl Drop for Reporter {
57 + fn drop(&mut self) {
58 + // Records survive the alternate screen. Remove ours on every exit path,
59 + // including unwinding. The TUI uses the protocol's root record.
60 + if self.last.is_some() {
61 + let _ = self.writer.write_all(b"\x1b]7501;state=clear\x1b\\");
62 + let _ = self.writer.flush();
63 + }
64 + }
65 +}
66 +
67 +#[cfg(test)]
68 +mod tests {
69 + use super::*;
70 + use std::sync::{Arc, Mutex};
71 +
72 + #[derive(Clone, Default)]
73 + struct Output(Arc<Mutex<Vec<u8>>>);
74 + impl Write for Output {
75 + fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
76 + self.0.lock().unwrap().extend_from_slice(bytes);
77 + Ok(bytes.len())
78 + }
79 + fn flush(&mut self) -> std::io::Result<()> {
80 + Ok(())
81 + }
82 + }
83 +
84 + #[test]
85 + fn lifecycle_reports_are_framed_deduplicated_and_cleared() {
86 + let output = Output::default();
87 + {
88 + let mut reporter = Reporter::new(output.clone());
89 + for status in [
90 + Status::Idle,
91 + Status::Working,
92 + Status::Working,
93 + Status::Permission,
94 + Status::Working,
95 + Status::Question,
96 + Status::Auth,
97 + Status::Done,
98 + Status::Error,
99 + Status::Idle,
100 + ] {
101 + reporter.report(status);
102 + }
103 + }
104 + let actual = String::from_utf8(output.0.lock().unwrap().clone()).unwrap();
105 + let expected = [
106 + "state=idle",
107 + "state=working",
108 + "state=blocked:kind=permission",
109 + "state=working",
110 + "state=blocked:kind=question",
111 + "state=blocked:kind=auth",
112 + "state=done",
113 + "state=error",
114 + "state=idle",
115 + ]
116 + .map(|pairs| format!("\x1b]7501;{pairs}:app=sigit\x1b\\"))
117 + .join("")
118 + + "\x1b]7501;state=clear\x1b\\";
119 + assert_eq!(actual, expected);
120 + }
121 +
122 + #[test]
123 + fn unwinding_clears_the_terminal_record() {
124 + let output = Output::default();
125 + let captured = output.clone();
126 + let _ = std::panic::catch_unwind(move || {
127 + let mut reporter = Reporter::new(captured);
128 + reporter.report(Status::Working);
129 + panic!("terminal loop failed");
130 + });
131 + assert!(
132 + output
133 + .0
134 + .lock()
135 + .unwrap()
136 + .ends_with(b"\x1b]7501;state=clear\x1b\\")
137 + );
138 + }
139 +
140 + #[test]
141 + fn unused_reporter_emits_nothing() {
142 + let output = Output::default();
143 + drop(Reporter::new(output.clone()));
144 + assert!(output.0.lock().unwrap().is_empty());
145 + }
146 +}