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
+}