Put the account's balance on the coder's bottom row

7cbe78af4f61 · AtlantisPleb · · parent 0c3b7992c8db

Put the account's balance on the coder's bottom row

The status bar carried this session's token counts and nothing about
money. `GET /api/v1/credit` now answers with what the account was granted,
what its grants metered, what is left, and how many of its calls the
deployment could not price, so the row can say whether the next turn is
affordable.

It is read, never computed. Spend is priced server-side, and a second
terminal signed in to the same account spends the same money — a client
subtracting what it saw would show one session's usage as an account's
balance, and would show a full one to whichever terminal opened second.

Three display states, because three things can be true and collapsing
them would make the row lie in one of them:

- nothing read yet, so the field is absent — not `$0.00`
- a read that did not answer: `credit: unavailable`, replacing any figure
  it had shown, because a number that stopped refreshing looks exactly
  like a current one
- an answer whose spend the server calls incomplete:
  `credit: 3 unpriced calls`, and no dollar figure at all

Only a complete answer prints one. `remaining_microusd` is a ceiling
rather than a balance while any lane is unpriced, and the lane this coder
runs on is unpriced today: a 12-task benchmark run on 2026-08-26 came
back with 0 of 12 calls priced. A row showing `$20.00 left` through that
afternoon would not read as "unknown", it would read as "you have spent
nothing".

`tests/interactive_pty.rs` drives both answered states end to end: the
stub deployment serves the two bodies the controller writes, and the
assertions read the figure and the unpriced count out of the emulated
cells.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 267 · 2026-08-26T15:40:28.942879Z

Changed files

  • added crates/coder-lite/src/credit.rs
  • modified crates/coder-lite/src/interactive.rs
  • modified crates/coder-lite/src/lib.rs
  • modified crates/coder-lite/src/runtime.rs
  • modified crates/coder-lite/src/tui.rs
  • modified crates/coder-lite/tests/interactive_pty.rs

Diff

6 files changed, +501 -18

crates/coder-lite/src/credit.rs added +295

@@ -0,0 +1,295 @@

1
//! The account's inference money, as the server reports it.
2
//!
3
//! ## Why this is read rather than counted
4
//!
5
//! Credit is the account's, not this session's: the server prices the call,
6
//! the account holds the balance, and a second terminal signed in to the same
7
//! account spends the same money. A client that subtracted what it saw would
8
//! show one session's spend as an account's, and would show a full balance to
9
//! the terminal that opened second. So the figure comes from
10
//! `GET /api/v1/credit` and nothing here computes one.
11
//!
12
//! ## Nothing is reported that was not received
13
//!
14
//! [`CreditField`] has three states because there are three things that can be
15
//! true, and a status bar that collapsed them would be lying in one of them:
16
//!
17
//! - **Nothing read yet.** The field is absent from the row. Not `$0.00`, and
18
//!   not a hopeful blank that looks like a balance of nothing.
19
//! - **Read, and the answer did not come back.** The row says the balance is
20
//!   unavailable. It does **not** keep showing the last figure it saw: a stale
21
//!   number beside a live session is indistinguishable from a current one.
22
//! - **Read, and the server answered.** Now there is a figure — with one
23
//!   exception below.
24
//!
25
//! ## Why an answer is sometimes still not a figure
26
//!
27
//! `remaining_microusd` is a ceiling, not a balance. A model this deployment
28
//! declares no rates for records no cost, so its calls draw nothing down and
29
//! the remainder does not move — and the lane the coder runs on is one of them
30
//! today. A status bar that printed the remainder anyway would sit at `$20.00`
31
//! through an afternoon of real work, and a figure a reader can watch not move
32
//! is worse than no figure: it does not read as "unknown", it reads as "you
33
//! have spent nothing". A 12-task benchmark run on 2026-08-26 returned 0 of 12
34
//! calls priced, so this is the measured state of the lane rather than a
35
//! precaution.
36
//!
37
//! So a figure is printed only while the server says the spend behind it is
38
//! complete. The moment an unpriced call is metered, the row says how many
39
//! calls the server could not price and prints no number. That is the same
40
//! rule the server's own `Credit.balance/1` documents (METER-001).
41
//!
42
//! The two no-figure states are worded differently on purpose. "Unavailable"
43
//! is *we did not hear back*; "N calls unpriced" is *we heard back and the
44
//! deployment cannot price part of this*. They are different failures, they
45
//! are fixed by different people, and a reader has to be able to tell them
46
//! apart.
47
48
use std::time::Duration;
49
50
use serde::Deserialize;
51
52
/// How long the status bar waits for a balance before giving up on this
53
/// refresh. Short on purpose: a slow deployment must not hold a frame, and
54
/// giving up says so rather than showing the previous answer.
55
const TIMEOUT: Duration = Duration::from_secs(5);
56
57
/// What `GET /api/v1/credit` answers with.
58
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
59
pub struct Credit {
60
    /// What this account was granted, in microUSD. Per account, not per
61
    /// deployment: an account created before the grant changed holds what it
62
    /// was granted.
63
    pub allowance_microusd: i64,
64
    /// What the account's grants have metered, in microUSD. A floor while
65
    /// `complete` is false.
66
    pub spent_microusd: i64,
67
    /// `allowance_microusd - spent_microusd`, never negative. A ceiling on
68
    /// what is left rather than a balance while `complete` is false.
69
    pub remaining_microusd: i64,
70
    /// How many of the account's metered calls landed on a model with no
71
    /// declared rates, and so drew nothing down.
72
    pub unpriced_calls: u64,
73
    /// Whether every call the account made was priced. False is the server
74
    /// saying its own figures are incomplete.
75
    pub complete: bool,
76
}
77
78
/// What the status bar knows about the balance right now.
79
#[derive(Debug, Clone, Default, PartialEq, Eq)]
80
pub enum CreditField {
81
    /// No read has answered yet, so the row says nothing about credit.
82
    #[default]
83
    Unread,
84
    /// A read was made and did not come back with an answer.
85
    Unavailable,
86
    /// The server answered, and this is what it said.
87
    Known(Credit),
88
}
89
90
#[derive(Debug, Deserialize)]
91
struct Envelope {
92
    credit: Credit,
93
}
94
95
impl CreditField {
96
    /// Record the outcome of one read. `None` is a read that did not answer.
97
    ///
98
    /// A failed read replaces a previous answer rather than leaving it up.
99
    /// That is the whole reason this takes the outcome instead of only the
100
    /// successes.
101
    pub fn record(&mut self, outcome: Option<Credit>) {
102
        *self = match outcome {
103
            Some(credit) => CreditField::Known(credit),
104
            None => CreditField::Unavailable,
105
        };
106
    }
107
108
    /// What the status bar prints for the balance, or nothing.
109
    ///
110
    /// Four answers for four states, and no two of them can be mistaken for
111
    /// each other: nothing at all before a read, `credit: unavailable` when a
112
    /// read failed, `credit: N calls unpriced` when the server's own spend is
113
    /// incomplete, and a dollar figure only when it is not.
114
    pub fn status(&self) -> String {
115
        match self {
116
            CreditField::Unread => String::new(),
117
            CreditField::Unavailable => "credit: unavailable".to_string(),
118
            CreditField::Known(credit) if credit.complete => {
119
                format!("{} left", dollars(credit.remaining_microusd))
120
            }
121
            CreditField::Known(credit) => {
122
                let calls = credit.unpriced_calls;
123
                let plural = if calls == 1 { "call" } else { "calls" };
124
                format!("credit: {calls} unpriced {plural}")
125
            }
126
        }
127
    }
128
}
129
130
/// microUSD as the dollars a reader recognises, rounded to the cent.
131
///
132
/// Rounded down, so the figure is never larger than what the account holds.
133
fn dollars(microusd: i64) -> String {
134
    let cents = microusd.max(0) / 10_000;
135
    format!("${}.{:02}", cents / 100, cents % 100)
136
}
137
138
/// Read the account's credit from the deployment this session is talking to.
139
///
140
/// `None` for every failure — no network, a refusal, a body that did not
141
/// parse. The caller records that as [`CreditField::Unavailable`] rather than
142
/// keeping what it last knew, because a figure that has stopped being refreshed
143
/// looks exactly like one that is current.
144
pub async fn fetch(api_base: &str, token: &str) -> Option<Credit> {
145
    let client = reqwest::Client::builder().timeout(TIMEOUT).build().ok()?;
146
    let url = format!("{}/credit", api_base.trim_end_matches('/'));
147
    let response = client.get(url).bearer_auth(token).send().await.ok()?;
148
    if !response.status().is_success() {
149
        return None;
150
    }
151
    let envelope: Envelope = response.json().await.ok()?;
152
    Some(envelope.credit)
153
}
154
155
#[cfg(test)]
156
mod tests {
157
    use super::*;
158
159
    /// The exact body `OpenAgentsWeb.CreditController` writes for an account
160
    /// that has spent $1.60 of a $20 grant on priced lanes.
161
    const PRICED: &str = r#"{"credit":{"allowance_microusd":20000000,
162
        "spent_microusd":1600000,"remaining_microusd":18400000,
163
        "unpriced_calls":0,"complete":true}}"#;
164
165
    /// The same account after turns on the coder's own unpriced lane: the
166
    /// remainder did not move, and the server says why.
167
    const UNPRICED: &str = r#"{"credit":{"allowance_microusd":20000000,
168
        "spent_microusd":0,"remaining_microusd":20000000,
169
        "unpriced_calls":3,"complete":false}}"#;
170
171
    fn parse(body: &str) -> Credit {
172
        serde_json::from_str::<Envelope>(body)
173
            .expect("the controller's body")
174
            .credit
175
    }
176
177
    fn known(body: &str) -> CreditField {
178
        CreditField::Known(parse(body))
179
    }
180
181
    #[test]
182
    fn nothing_is_said_before_anything_is_read() {
183
        assert_eq!(CreditField::default().status(), "");
184
    }
185
186
    #[test]
187
    fn a_complete_balance_prints_the_figure() {
188
        assert_eq!(known(PRICED).status(), "$18.40 left");
189
    }
190
191
    #[test]
192
    fn an_incomplete_balance_prints_no_figure_at_all() {
193
        let status = known(UNPRICED).status();
194
195
        assert_eq!(status, "credit: 3 unpriced calls");
196
        // The property, stated rather than implied: the remainder is $20.00
197
        // and this is the account that spent real tokens on an unpriced lane,
198
        // so the one thing the line must not contain is that figure.
199
        assert!(
200
            !status.contains("20.00") && !status.contains('$'),
201
            "an unpriced balance must not print a dollar figure: {status:?}"
202
        );
203
    }
204
205
    #[test]
206
    fn one_unpriced_call_reads_as_one_call() {
207
        let CreditField::Known(mut credit) = known(UNPRICED) else {
208
            unreachable!("known/1 returns a known field")
209
        };
210
        credit.unpriced_calls = 1;
211
212
        assert_eq!(
213
            CreditField::Known(credit).status(),
214
            "credit: 1 unpriced call"
215
        );
216
    }
217
218
    #[test]
219
    fn a_failed_read_says_so_rather_than_zero() {
220
        let mut field = CreditField::default();
221
        field.record(None);
222
223
        let status = field.status();
224
        assert_eq!(status, "credit: unavailable");
225
        assert!(
226
            !status.contains('$') && !status.contains('0'),
227
            "an unavailable balance must not print a figure: {status:?}"
228
        );
229
    }
230
231
    /// The failure the whole three-state shape exists for.
232
    #[test]
233
    fn a_failed_read_replaces_the_previous_figure_rather_than_leaving_it_up() {
234
        let mut field = CreditField::default();
235
        field.record(Some(parse(PRICED)));
236
        assert_eq!(field.status(), "$18.40 left");
237
238
        field.record(None);
239
240
        assert_eq!(field.status(), "credit: unavailable");
241
    }
242
243
    /// The two no-figure states are distinguishable, which is the point of
244
    /// having two of them.
245
    #[test]
246
    fn an_unavailable_read_and_an_unpriced_lane_do_not_read_alike() {
247
        assert_ne!(CreditField::Unavailable.status(), known(UNPRICED).status());
248
    }
249
250
    #[test]
251
    fn a_fresh_account_prints_its_whole_grant() {
252
        let credit = Credit {
253
            allowance_microusd: 20_000_000,
254
            spent_microusd: 0,
255
            remaining_microusd: 20_000_000,
256
            unpriced_calls: 0,
257
            complete: true,
258
        };
259
260
        assert_eq!(CreditField::Known(credit).status(), "$20.00 left");
261
    }
262
263
    #[test]
264
    fn an_account_granted_before_the_figure_changed_prints_what_it_holds() {
265
        let credit = Credit {
266
            allowance_microusd: 100_000_000,
267
            spent_microusd: 0,
268
            remaining_microusd: 100_000_000,
269
            unpriced_calls: 0,
270
            complete: true,
271
        };
272
273
        assert_eq!(CreditField::Known(credit).status(), "$100.00 left");
274
    }
275
276
    #[test]
277
    fn an_exhausted_account_prints_zero_rather_than_nothing() {
278
        let credit = Credit {
279
            allowance_microusd: 20_000_000,
280
            spent_microusd: 20_000_000,
281
            remaining_microusd: 0,
282
            unpriced_calls: 0,
283
            complete: true,
284
        };
285
286
        assert_eq!(CreditField::Known(credit).status(), "$0.00 left");
287
    }
288
289
    #[test]
290
    fn cents_are_rounded_down_rather_than_up() {
291
        // $0.019999 is not two cents to an account that has to pay for it.
292
        assert_eq!(dollars(19_999), "$0.01");
293
        assert_eq!(dollars(-1), "$0.00");
294
    }
295
}
crates/coder-lite/src/interactive.rs modified +42

@@ -145,9 +145,23 @@ pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::

145 145
    let mut terminal = Terminal::new(backend)?;
146 146
    terminal.show_cursor()?;
147 147
148
    // What the account holds before this session has spent anything, so the
149
    // bottom row carries a balance from the first frame rather than only after
150
    // a turn.
151
    if session.is_some() {
152
        refresh_credit(&tx);
153
    }
154
148 155
    loop {
149 156
        while let Ok(control) = rx.try_recv() {
157
            // A turn that has stopped, either way, is the moment the server's
158
            // figure can have moved. Read it again rather than adjusting the
159
            // one on screen: this terminal is not the only thing spending.
160
            let settled = matches!(control, Control::Done | Control::Failed(_));
150 161
            apply(&mut ui, control);
162
            if settled {
163
                refresh_credit(&tx);
164
            }
151 165
        }
152 166
153 167
        terminal.draw(|f| {

@@ -227,6 +241,9 @@ pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::

227 241
                                    crate::runtime::api_base()
228 242
                                ),
229 243
                            ));
244
                            // The account is only now known, so this is the
245
                            // first read that can answer.
246
                            refresh_credit(&tx);
230 247
                        }
231 248
                        Err(error) => {
232 249
                            ui.entries.push(Entry::new(

@@ -281,6 +298,27 @@ pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::

281 298
    Ok(())
282 299
}
283 300
301
/// Ask the deployment what the account has left, off the frame loop.
302
///
303
/// Spawned rather than awaited, because a slow deployment must not hold a
304
/// frame: the answer arrives as a [`Control`] like everything else. The
305
/// outcome is sent either way, including when there is no credential to ask
306
/// with — the bottom row has to stop showing a figure it can no longer
307
/// confirm, and `None` is what tells it to.
308
fn refresh_credit(tx: &Sender<Control>) {
309
    let token = crate::runtime::user_token();
310
    let base = crate::runtime::api_base();
311
    let tx = tx.clone();
312
313
    tokio::spawn(async move {
314
        let outcome = match token {
315
            Some(token) => crate::credit::fetch(&base, &token).await,
316
            None => None,
317
        };
318
        let _ = tx.send(Control::Credit(outcome));
319
    });
320
}
321
284 322
/// Check that a token is accepted by the deployment without calling GitHub.
285 323
/// `GET /api/v1/models` is a light, non-GitHub endpoint that still requires a
286 324
/// valid bearer token, so a 200 here means the token is good to spend.

@@ -418,6 +456,10 @@ pub fn apply(ui: &mut CoderUi, control: Control) {

418 456
                ui.add_usage(usage);
419 457
            }
420 458
        }
459
        // Replaced rather than accumulated: it is the account's balance as of
460
        // that read, not a delta this session can add up. A read that found
461
        // nothing replaces the figure too — see `crate::credit`.
462
        Control::Credit(outcome) => ui.credit.record(outcome),
421 463
        Control::Notice(text) => {
422 464
            if !text.trim().is_empty() {
423 465
                ui.entries.push(Entry::new(Role::Notice, text));
crates/coder-lite/src/lib.rs modified +1

@@ -9,6 +9,7 @@ pub mod acp;

9 9
pub mod acp_harness;
10 10
pub mod acp_tool;
11 11
pub mod commands;
12
pub mod credit;
12 13
pub mod export;
13 14
pub mod interactive;
14 15
pub mod markdown;
crates/coder-lite/src/runtime.rs modified +6

@@ -58,6 +58,12 @@ pub enum Control {

58 58
    Model(String),
59 59
    /// What the turn spent, as the server reported it.
60 60
    Usage(TurnUsage),
61
    /// What one read of `GET /api/v1/credit` found, and `None` when it found
62
    /// nothing. Read rather than subtracted from `Usage`: spend is the
63
    /// server's, and this session is not the only thing spending it. The
64
    /// failure travels too, because the frame has to stop showing the previous
65
    /// answer rather than leave it up.
66
    Credit(Option<crate::credit::Credit>),
61 67
    /// Something worth saying that is not the model talking.
62 68
    Notice(String),
63 69
    /// What one of the session's own commands printed. Markdown, rendered the
crates/coder-lite/src/tui.rs modified +35 -6

@@ -174,6 +174,13 @@ pub struct CoderUi {

174 174
    pub loading: bool,
175 175
    pub tick: u64,
176 176
    pub total_usage: TurnUsage,
177
    /// What the last read of the account's credit found, or that it found
178
    /// nothing.
179
    ///
180
    /// Nothing here derives it from [`Self::total_usage`]: that total is this
181
    /// session's, and the credit is the account's. See [`crate::credit`] for
182
    /// why a failed read clears this rather than leaving the last figure up.
183
    pub credit: crate::credit::CreditField,
177 184
    pub agents: Vec<crate::acp::Agent>,
178 185
    /// Hyperlinks on the last rendered frame, in absolute screen coordinates.
179 186
    ///

@@ -233,6 +240,7 @@ impl CoderUi {

233 240
            loading: false,
234 241
            tick: 0,
235 242
            total_usage: TurnUsage::default(),
243
            credit: crate::credit::CreditField::Unread,
236 244
            agents: Vec::new(),
237 245
            links: Vec::new(),
238 246
        }

@@ -243,6 +251,32 @@ impl CoderUi {

243 251
        self.total_usage.add(usage);
244 252
    }
245 253
254
    /// The bottom row, as a `·`-joined list of the fields that have something
255
    /// to say.
256
    ///
257
    /// The credit and the tokens are facts about two different things, which
258
    /// is why they are separate fields rather than one combined figure: the
259
    /// tokens are this terminal's turn totals, and the credit is the account's,
260
    /// read from the server, and possibly moved by another terminal since. A
261
    /// field with nothing to report contributes nothing, so the row never
262
    /// carries a placeholder that could be read as a value.
263
    pub fn status_line(credit: &crate::credit::CreditField, usage: &TurnUsage) -> String {
264
        let fields = [
265
            credit.status(),
266
            format!(
267
                "{} prompt + {} completion = {} tokens",
268
                usage.prompt_tokens, usage.completion_tokens, usage.total_tokens
269
            ),
270
        ];
271
272
        fields
273
            .iter()
274
            .filter(|field| !field.is_empty())
275
            .cloned()
276
            .collect::<Vec<_>>()
277
            .join(" · ")
278
    }
279
246 280
    pub fn render(&mut self, frame: &mut Frame, area: Rect) {
247 281
        self.tick = self.tick.wrapping_add(1);
248 282
        let style = Style::default().fg(TEXT_COLOR).bg(BACKGROUND_COLOR);

@@ -340,12 +374,7 @@ impl CoderUi {

340 374
        frame.set_cursor_position(Position::new(cursor_x, cursor_y));
341 375
342 376
        let status_area = main[2];
343
        let status = format!(
344
            "{} prompt + {} completion = {} tokens",
345
            self.total_usage.prompt_tokens,
346
            self.total_usage.completion_tokens,
347
            self.total_usage.total_tokens
348
        );
377
        let status = Self::status_line(&self.credit, &self.total_usage);
349 378
        let status_widget = Paragraph::new(status)
350 379
            .style(style)
351 380
            .alignment(ratatui::layout::Alignment::Right);
crates/coder-lite/tests/interactive_pty.rs modified +122 -12

@@ -34,10 +34,11 @@

34 34
//! `run_tui` opens a session only when a stored credential validates against
35 35
//! `GET {origin}/api/v1/models`. The harness therefore points
36 36
//! `OPENAGENTS_API_URL` at a stub HTTP server it starts on loopback, which
37
//! answers that one route with `200` and refuses everything else. So the
38
//! session is real, the frame is real, no provider credential is spent, and
39
//! nothing leaves the machine. A turn is never completed on purpose: what is
40
//! asserted is that Enter *starts* one, which is the part the composer owns.
37
//! answers that route and `GET {origin}/api/v1/credit` — the one the status
38
//! bar reads its balance from — with `200`, and refuses everything else. So
39
//! the session is real, the frame is real, no provider credential is spent,
40
//! and nothing leaves the machine. A turn is never completed on purpose: what
41
//! is asserted is that Enter *starts* one, which is the part the composer owns.
41 42
42 43
#[cfg(not(unix))]
43 44
#[test]

@@ -75,19 +76,40 @@ mod unix_pty {

75 76
76 77
    // ─────────────────────────────────────────────────── the stub deployment
77 78
78
    /// A loopback HTTP server that answers exactly one route.
79
    /// A loopback HTTP server that answers exactly two routes.
79 80
    ///
80 81
    /// `GET …/api/v1/models` is what `validate_token` calls to decide whether
81
    /// a session may open, and it is the only thing this harness wants a
82
    /// deployment for. Everything else is refused with `503` and a body that
82
    /// a session may open. `GET …/api/v1/credit` is what the status bar reads
83
    /// its balance from, and it answers the exact body
84
    /// `OpenAgentsWeb.CreditController` writes — so the figure on the bottom
85
    /// row is one this binary parsed out of an HTTP response rather than one a
86
    /// test handed it. Everything else is refused with `503` and a body that
83 87
    /// says who refused it, so a request this harness did not intend is
84 88
    /// legible rather than silently satisfied.
85 89
    struct Stub {
86 90
        origin: String,
87 91
    }
88 92
93
    /// A $20 account that has spent $1.60 on priced lanes, every call priced.
94
    const STUB_CREDIT: &str = concat!(
95
        r#"{"credit":{"allowance_microusd":20000000,"spent_microusd":1600000,"#,
96
        r#""remaining_microusd":18400000,"unpriced_calls":0,"complete":true}}"#
97
    );
98
99
    /// The same account after three turns on a lane this deployment has no
100
    /// rates for: nothing was drawn down, and the server says its own spend
101
    /// figure is incomplete.
102
    const STUB_CREDIT_UNPRICED: &str = concat!(
103
        r#"{"credit":{"allowance_microusd":20000000,"spent_microusd":0,"#,
104
        r#""remaining_microusd":20000000,"unpriced_calls":3,"complete":false}}"#
105
    );
106
89 107
    impl Stub {
90 108
        fn start() -> Self {
109
            Self::start_with_credit(STUB_CREDIT)
110
        }
111
112
        fn start_with_credit(credit: &'static str) -> Self {
91 113
            let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback stub");
92 114
            let port = listener.local_addr().expect("stub address").port();
93 115
            std::thread::spawn(move || {

@@ -102,15 +124,22 @@ mod unix_pty {

102 124
                        }
103 125
                    }
104 126
                    let head = String::from_utf8_lossy(&request).to_string();
105
                    let response = if head.starts_with("GET ") && head.contains("/api/v1/models") {
106
                        let body = r#"{"data":[]}"#;
127
                    let served = if head.starts_with("GET ") && head.contains("/api/v1/models") {
128
                        Some(r#"{"data":[]}"#)
129
                    } else if head.starts_with("GET ") && head.contains("/api/v1/credit") {
130
                        Some(credit)
131
                    } else {
132
                        None
133
                    };
134
                    let response = if let Some(body) = served {
107 135
                        format!(
108 136
                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
109 137
                             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
110 138
                            body.len()
111 139
                        )
112 140
                    } else {
113
                        let body = "the coder-lite PTY harness stub serves /api/v1/models only";
141
                        let body =
142
                            "the coder-lite PTY harness stub serves /api/v1/models and /credit";
114 143
                        format!(
115 144
                            "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\n\
116 145
                             Content-Length: {}\r\nConnection: close\r\n\r\n{body}",

@@ -266,8 +295,19 @@ mod unix_pty {

266 295
            Self::start_sized(ROWS, COLS)
267 296
        }
268 297
298
        /// A session whose deployment answers `GET /api/v1/credit` with one
299
        /// named body, so a test can drive the status bar's states from the
300
        /// wire rather than from the renderer.
301
        fn start_with_credit(credit: &'static str) -> Self {
302
            Self::start_full(ROWS, COLS, credit)
303
        }
304
269 305
        fn start_sized(rows: u16, cols: u16) -> Self {
270
            let stub = Stub::start();
306
            Self::start_full(rows, cols, STUB_CREDIT)
307
        }
308
309
        fn start_full(rows: u16, cols: u16, credit: &'static str) -> Self {
310
            let stub = Stub::start_with_credit(credit);
271 311
            let home = scratch_dir();
272 312
            let workdir = home.join("workdir");
273 313
            std::fs::create_dir_all(&workdir).expect("create the scratch working directory");

@@ -608,7 +648,7 @@ mod unix_pty {

608 648
            |frame| {
609 649
                frame
610 650
                    .transcript()
611
                    .contains("the coder-lite PTY harness stub serves /api/v1/models only")
651
                    .contains("the coder-lite PTY harness stub serves /api/v1/models and /credit")
612 652
            },
613 653
        );
614 654
    }

@@ -725,6 +765,76 @@ mod unix_pty {

725 765
        );
726 766
    }
727 767
768
    /// The balance on the bottom row is the deployment's, not this session's.
769
    ///
770
    /// The stub answers `GET /api/v1/credit` with the body the server writes
771
    /// for a $20 account that has spent $1.60, so what this asserts is a figure
772
    /// that travelled over HTTP, through `serde`, into the frame — not one a
773
    /// test handed the renderer. Nothing in the session has spent a token, so a
774
    /// build that derived the balance from its own usage counter would show the
775
    /// whole $20.00 here and go red.
776
    #[test]
777
    fn the_status_bar_carries_the_balance_the_server_reported() {
778
        let tui = Tui::start();
779
        let frame = tui.wait_for("the status bar to carry a balance", FIRST_FRAME, |frame| {
780
            frame.status_bar().contains("$18.40")
781
        });
782
783
        let status = frame.status_bar();
784
        assert!(
785
            status.contains("$18.40 left"),
786
            "the bottom row should carry the remaining balance, and held {:?}.\n{}",
787
            status,
788
            frame.dump()
789
        );
790
        assert!(
791
            status.contains("tokens"),
792
            "the balance goes beside the token counts, not instead of them: {:?}.\n{}",
793
            status,
794
            frame.dump()
795
        );
796
        // $20.00 is the allowance, and printing it would be reporting a grant
797
        // as a balance.
798
        assert!(
799
            !status.contains("$20.00"),
800
            "the bottom row printed the allowance rather than the remainder: {:?}.\n{}",
801
            status,
802
            frame.dump()
803
        );
804
    }
805
806
    /// The other display state, and the one the coder's own lane is in today.
807
    ///
808
    /// The deployment answers with a remainder of $20.00 that three unpriced
809
    /// calls did not move, and says so. The bottom row must report the calls it
810
    /// cannot see rather than the figure it was handed: a status bar showing
811
    /// `$20.00 left` beside a session that has been working all afternoon is
812
    /// the exact failure `Credit.unpriced_calls/1` exists to prevent.
813
    #[test]
814
    fn an_unpriced_lane_reports_what_it_cannot_see_rather_than_a_figure() {
815
        let tui = Tui::start_with_credit(STUB_CREDIT_UNPRICED);
816
        let frame = tui.wait_for(
817
            "the status bar to report the unpriced calls",
818
            FIRST_FRAME,
819
            |frame| frame.status_bar().contains("unpriced"),
820
        );
821
822
        let status = frame.status_bar();
823
        assert!(
824
            status.contains("credit: 3 unpriced calls"),
825
            "the bottom row should name the calls the server could not price, \
826
             and held {:?}.\n{}",
827
            status,
828
            frame.dump()
829
        );
830
        assert!(
831
            !status.contains('$'),
832
            "an unpriced balance must print no dollar figure at all: {:?}.\n{}",
833
            status,
834
            frame.dump()
835
        );
836
    }
837
728 838
    /// Leaving hands the terminal back: raw mode off, alternate screen left.
729 839
    ///
730 840
    /// The termios check is read from the pty master after the child is gone,

This page updates live while a promote is in flight · changelog