Skip to main content

pi/modes/interactive/
progress.rs

1//! Progress view-models: OAuth login progress, compaction/retry/bash progress,
2//! and the pending (steering/follow-up) queue.
3//!
4//! These are pure data → pi-tui `Text`/`Loader` builders, decoupled from the
5//! live session. The runtime feeds [`super::state`] snapshots; the composer
6//! splices the built components above the editor.
7
8use pi_tui::component::Component;
9use pi_tui::components::{Loader, Spacer, Text};
10
11use super::state::{
12    AuthProgress, BashProgress, CompactionProgress, OAuthStage, PendingKind, PendingQueue,
13    RetryProgress,
14};
15use super::theme::{self, ResolvedTheme, ThemeColor};
16
17// ---------------------------------------------------------------------------
18// Pending queue
19// ---------------------------------------------------------------------------
20
21/// Build the pending-messages component (steering + follow-up queue).
22///
23/// Renders one styled line per queued message, prefixed by kind.
24#[must_use]
25pub fn build_pending(queue: &PendingQueue, th: &ResolvedTheme) -> Box<dyn Component> {
26    let mut stack = super::messages::ColumnStack::new();
27    for msg in &queue.steering {
28        stack.push(Box::new(Text::with_padding(
29            pending_line(PendingKind::Steering, &msg.text, th),
30            1,
31            0,
32        )));
33    }
34    for msg in &queue.follow_up {
35        stack.push(Box::new(Text::with_padding(
36            pending_line(PendingKind::FollowUp, &msg.text, th),
37            1,
38            0,
39        )));
40    }
41    if !queue.follow_up.is_empty() {
42        let mode = match queue.follow_up_mode {
43            super::state::QueueMode::All => "all queued follow-ups will send after this turn",
44            super::state::QueueMode::OneAtATime => "follow-ups send one at a time",
45        };
46        stack.push(Box::new(Text::with_padding(
47            th.fg(ThemeColor::Dim, mode),
48            1,
49            0,
50        )));
51    }
52    if stack.is_empty() {
53        stack.push(Box::new(Spacer::new(0)));
54    }
55    Box::new(stack)
56}
57
58fn pending_line(kind: PendingKind, text: &str, th: &ResolvedTheme) -> String {
59    let (glyph, label) = match kind {
60        PendingKind::Steering => ("↳", "steer"),
61        PendingKind::FollowUp => ("→", "queued"),
62    };
63    format!(
64        "{} {}",
65        th.fg(ThemeColor::Accent, glyph),
66        th.fg(ThemeColor::Muted, &format!("{label}: ")),
67    ) + text
68}
69
70// ---------------------------------------------------------------------------
71// OAuth / auth progress
72// ---------------------------------------------------------------------------
73
74/// Build the auth-progress component (login dialog status line + spinner).
75#[must_use]
76pub fn build_auth_progress(progress: &AuthProgress, th: &ResolvedTheme) -> Box<dyn Component> {
77    let mut loader = Loader::new(
78        move |s: &str| theme::current().fg(ThemeColor::Accent, s),
79        move |s: &str| theme::current().fg(ThemeColor::Muted, s),
80        auth_stage_message(progress, th),
81        None,
82    );
83    loader.set_frame_index(0);
84    match progress.stage {
85        OAuthStage::Failed => Box::new(Text::with_padding(
86            th.fg(ThemeColor::Error, &auth_stage_message(progress, th)),
87            1,
88            0,
89        )),
90        OAuthStage::Done => Box::new(Text::with_padding(
91            th.fg(ThemeColor::Success, &auth_stage_message(progress, th)),
92            1,
93            0,
94        )),
95        _ => Box::new(loader),
96    }
97}
98
99/// The human-readable auth-stage message.
100#[must_use]
101pub fn auth_stage_message(progress: &AuthProgress, th: &ResolvedTheme) -> String {
102    match progress.stage {
103        OAuthStage::BrowserCallback => {
104            let base = format!("Opening browser to log in to {}…", progress.provider);
105            if let Some(url) = progress.detail.as_deref() {
106                format!("{} {}", base, th.fg(ThemeColor::MdLink, url))
107            } else {
108                base
109            }
110        }
111        OAuthStage::DeviceCode => {
112            let base = format!("Device flow for {} — enter code:", progress.provider);
113            if let Some(code) = progress.detail.as_deref() {
114                format!("{base} {}", theme::bold(code))
115            } else {
116                base
117            }
118        }
119        OAuthStage::ManualKey => format!("Enter API key for {}:", progress.provider),
120        OAuthStage::Exchanging => format!("Exchanging token for {}…", progress.provider),
121        OAuthStage::Done => format!("Logged in to {}.", progress.provider),
122        OAuthStage::Failed => format!("Failed to log in to {}.", progress.provider),
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Compaction / retry / bash progress
128// ---------------------------------------------------------------------------
129
130/// Build the compaction-progress component (a working status with reason text).
131#[must_use]
132pub fn build_compaction_progress(
133    progress: &CompactionProgress,
134    th: &ResolvedTheme,
135) -> Box<dyn Component> {
136    let msg = super::status::compaction_message(progress.reason);
137    let accent = th.clone();
138    let muted = th.clone();
139    let mut loader = Loader::new(
140        move |s: &str| accent.fg(ThemeColor::Accent, s),
141        move |s: &str| muted.fg(ThemeColor::Muted, s),
142        msg,
143        None,
144    );
145    loader.set_frame_index(0);
146    Box::new(loader)
147}
148
149/// Build the retry-progress component with a countdown message.
150#[must_use]
151pub fn build_retry_progress(progress: &RetryProgress, th: &ResolvedTheme) -> Box<dyn Component> {
152    let msg =
153        super::status::retry_message(progress.attempt, progress.max_attempts, progress.seconds);
154    let warn = th.clone();
155    let muted = th.clone();
156    let mut loader = Loader::new(
157        move |s: &str| warn.fg(ThemeColor::Warning, s),
158        move |s: &str| muted.fg(ThemeColor::Muted, s),
159        msg,
160        None,
161    );
162    loader.set_frame_index(0);
163    Box::new(loader)
164}
165
166/// Build the bash-progress component (live command + output preview).
167#[must_use]
168pub fn build_bash_progress(progress: &BashProgress, th: &ResolvedTheme) -> Box<dyn Component> {
169    super::messages::build_bash(
170        &super::messages::BashMessageView {
171            command: progress.command.clone(),
172            output: progress.output.clone(),
173            expanded: progress.expanded,
174            exit_code: progress.exit_code,
175            cancelled: progress.cancelled,
176            truncated: false,
177            full_output_path: None,
178        },
179        th,
180    )
181}