Add the diff inspector, markdown, history, completion, and a pty to the coder TUI

43c92585c635 · AtlantisPleb · · parent c48fa5b1389f

Add the diff inspector, markdown, history, completion, and a pty to the coder TUI

The composer that landed in c8d2f76040 was the foundation the rest of #73
and #68 sat on. This is the rest of it.

Diff inspector. `crates/openagents-cli/src/diff.rs` computes a line diff
with Myers' algorithm over a bounded edit distance, parses `git diff`
output into the same shape, and renders either unified or side by side.
`/diff` runs git; `/diff <old> <new>` compares two files directly. The
inspector takes the keyboard while it is up, and its own status bar names
only its own keys.

Streaming markdown and highlighting. `markdown.rs` renders a reply as it
arrives: nothing waits for a closing delimiter, so an unclosed `**` draws
as the two characters that are there and re-draws as bold when the chunk
that closes it lands. Fenced blocks get a rail and a per-language lexer
for eleven languages; an unknown language is left plain.

Input history and completion. `composer/history.rs` walks previous
prompts with Up and Down, keeps the draft, and survives a restart in
`~/.config/openagents/coder-history`. `composer/complete.rs` completes
slash commands and paths on Tab, and never inserts a candidate that was
not the only one.

The status bar. It reads `last_model` rather than the grant — the local
lane resolves its model with Ollama and holds no grant — plus
`last_usage`, `Lane::label`, and `Lane::tier`. Segments are dropped whole
when the window is narrow, because `Model: ox-alp` names no model.

A pseudoterminal (#68). `pty.rs` adapts grok-build's `ptyctl`:
`portable-pty` for the pair, one thread that reads and then waits, a
`vt100` emulator rendered cell for cell into the frame, and keys encoded
as the bytes a terminal would send. `/run <command>` runs a program in
the pane; a resize reaches it as SIGWINCH; `Ctrl+]` takes the keyboard
back. `git log` run this way prints its decorations, which it only does
when it believes it is on a terminal.

Two crashes found by running the binary rather than reading it. `vt100`
underflows on a screen one row or one column across, which is exactly
what a terminal reporting no size hands it, so nothing narrower than two
is built. And a program that does not exist reported the operating
system's error wrapped in a debug print of the whole environment, so it
is now checked for first.

Every key the status bar names is pressed by a test, in each of the three
panes. 406 tests pass in the crate, up from 268.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

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

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified crates/openagents-cli/Cargo.toml
  • added crates/openagents-cli/src/composer/complete.rs
  • added crates/openagents-cli/src/composer/history.rs
  • modified crates/openagents-cli/src/composer/mod.rs
  • added crates/openagents-cli/src/diff.rs
  • modified crates/openagents-cli/src/interactive.rs
  • modified crates/openagents-cli/src/lib.rs
  • added crates/openagents-cli/src/markdown.rs
  • added crates/openagents-cli/src/pty.rs
  • modified crates/openagents-cli/src/tui.rs
  • modified crates/openagents-cli/tests/coder_tui_test.rs
  • modified crates/openagents-cli/tests/support/mod.rs

Diff

13 files changed, +6323 -115

Cargo.lock modified +129 -2

@@ -384,6 +384,12 @@ version = "1.0.4"

384 384
source = "registry+https://github.com/rust-lang/crates.io-index"
385 385
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
386 386
387
[[package]]
388
name = "cfg_aliases"
389
version = "0.1.1"
390
source = "registry+https://github.com/rust-lang/crates.io-index"
391
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
392
387 393
[[package]]
388 394
name = "cfg_aliases"
389 395
version = "0.2.1"

@@ -929,6 +935,12 @@ dependencies = [

929 935
 "syn 2.0.117",
930 936
]
931 937
938
[[package]]
939
name = "downcast-rs"
940
version = "1.2.1"
941
source = "registry+https://github.com/rust-lang/crates.io-index"
942
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
943
932 944
[[package]]
933 945
name = "dunce"
934 946
version = "1.0.5"

@@ -1053,6 +1065,17 @@ dependencies = [

1053 1065
 "subtle",
1054 1066
]
1055 1067
1068
[[package]]
1069
name = "filedescriptor"
1070
version = "0.8.3"
1071
source = "registry+https://github.com/rust-lang/crates.io-index"
1072
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
1073
dependencies = [
1074
 "libc",
1075
 "thiserror 1.0.69",
1076
 "winapi",
1077
]
1078
1056 1079
[[package]]
1057 1080
name = "find-msvc-tools"
1058 1081
version = "0.1.9"

@@ -1915,6 +1938,18 @@ dependencies = [

1915 1938
 "jni-sys 0.3.1",
1916 1939
]
1917 1940
1941
[[package]]
1942
name = "nix"
1943
version = "0.28.0"
1944
source = "registry+https://github.com/rust-lang/crates.io-index"
1945
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
1946
dependencies = [
1947
 "bitflags 2.11.1",
1948
 "cfg-if",
1949
 "cfg_aliases 0.1.1",
1950
 "libc",
1951
]
1952
1918 1953
[[package]]
1919 1954
name = "nom"
1920 1955
version = "7.1.3"

@@ -2162,6 +2197,7 @@ dependencies = [

2162 2197
 "libc",
2163 2198
 "openagents-all-work-contract",
2164 2199
 "openagents-cloud-contract",
2200
 "portable-pty",
2165 2201
 "ratatui",
2166 2202
 "regex",
2167 2203
 "reqwest 0.12.28",

@@ -2179,6 +2215,7 @@ dependencies = [

2179 2215
 "tungstenite",
2180 2216
 "unicode-segmentation",
2181 2217
 "unicode-width 0.2.0",
2218
 "vt100",
2182 2219
 "wasmtime",
2183 2220
 "wat",
2184 2221
 "zeroize",

@@ -2286,6 +2323,27 @@ dependencies = [

2286 2323
 "time",
2287 2324
]
2288 2325
2326
[[package]]
2327
name = "portable-pty"
2328
version = "0.9.0"
2329
source = "registry+https://github.com/rust-lang/crates.io-index"
2330
checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
2331
dependencies = [
2332
 "anyhow",
2333
 "bitflags 1.3.2",
2334
 "downcast-rs",
2335
 "filedescriptor",
2336
 "lazy_static",
2337
 "libc",
2338
 "log",
2339
 "nix",
2340
 "serial2",
2341
 "shared_library",
2342
 "shell-words",
2343
 "winapi",
2344
 "winreg",
2345
]
2346
2289 2347
[[package]]
2290 2348
name = "postcard"
2291 2349
version = "1.1.3"

@@ -2407,7 +2465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

2407 2465
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
2408 2466
dependencies = [
2409 2467
 "bytes",
2410
 "cfg_aliases",
2468
 "cfg_aliases 0.2.1",
2411 2469
 "pin-project-lite",
2412 2470
 "quinn-proto",
2413 2471
 "quinn-udp",

@@ -2448,7 +2506,7 @@ version = "0.5.14"

2448 2506
source = "registry+https://github.com/rust-lang/crates.io-index"
2449 2507
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
2450 2508
dependencies = [
2451
 "cfg_aliases",
2509
 "cfg_aliases 0.2.1",
2452 2510
 "libc",
2453 2511
 "once_cell",
2454 2512
 "socket2",

@@ -3015,6 +3073,17 @@ dependencies = [

3015 3073
 "serde",
3016 3074
]
3017 3075
3076
[[package]]
3077
name = "serial2"
3078
version = "0.2.38"
3079
source = "registry+https://github.com/rust-lang/crates.io-index"
3080
checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
3081
dependencies = [
3082
 "cfg-if",
3083
 "libc",
3084
 "windows-sys 0.61.2",
3085
]
3086
3018 3087
[[package]]
3019 3088
name = "sha1"
3020 3089
version = "0.10.7"

@@ -3046,6 +3115,22 @@ dependencies = [

3046 3115
 "lazy_static",
3047 3116
]
3048 3117
3118
[[package]]
3119
name = "shared_library"
3120
version = "0.1.9"
3121
source = "registry+https://github.com/rust-lang/crates.io-index"
3122
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
3123
dependencies = [
3124
 "lazy_static",
3125
 "libc",
3126
]
3127
3128
[[package]]
3129
name = "shell-words"
3130
version = "1.1.1"
3131
source = "registry+https://github.com/rust-lang/crates.io-index"
3132
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
3133
3049 3134
[[package]]
3050 3135
name = "shlex"
3051 3136
version = "1.3.0"

@@ -3778,6 +3863,39 @@ version = "0.9.5"

3778 3863
source = "registry+https://github.com/rust-lang/crates.io-index"
3779 3864
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
3780 3865
3866
[[package]]
3867
name = "vt100"
3868
version = "0.15.2"
3869
source = "registry+https://github.com/rust-lang/crates.io-index"
3870
checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de"
3871
dependencies = [
3872
 "itoa",
3873
 "log",
3874
 "unicode-width 0.1.14",
3875
 "vte",
3876
]
3877
3878
[[package]]
3879
name = "vte"
3880
version = "0.11.1"
3881
source = "registry+https://github.com/rust-lang/crates.io-index"
3882
checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197"
3883
dependencies = [
3884
 "arrayvec",
3885
 "utf8parse",
3886
 "vte_generate_state_changes",
3887
]
3888
3889
[[package]]
3890
name = "vte_generate_state_changes"
3891
version = "0.1.2"
3892
source = "registry+https://github.com/rust-lang/crates.io-index"
3893
checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e"
3894
dependencies = [
3895
 "proc-macro2",
3896
 "quote",
3897
]
3898
3781 3899
[[package]]
3782 3900
name = "walkdir"
3783 3901
version = "2.5.0"

@@ -4623,6 +4741,15 @@ dependencies = [

4623 4741
 "memchr",
4624 4742
]
4625 4743
4744
[[package]]
4745
name = "winreg"
4746
version = "0.10.1"
4747
source = "registry+https://github.com/rust-lang/crates.io-index"
4748
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
4749
dependencies = [
4750
 "winapi",
4751
]
4752
4626 4753
[[package]]
4627 4754
name = "wit-bindgen"
4628 4755
version = "0.57.1"
crates/openagents-cli/Cargo.toml modified +2

@@ -52,6 +52,8 @@ wasmtime = { version = "36", default-features = false, features = ["cranelift",

52 52
# `ring` is already in the tree underneath `rustls`, so this adds an audited
53 53
# AEAD and CSPRNG without adding a crate to the lock.
54 54
ring = "0.17"
55
vt100 = "0.15"
56
portable-pty = "0.9"
55 57
56 58
[dev-dependencies]
57 59
tempfile = "3"
crates/openagents-cli/src/composer/complete.rs added +324

@@ -0,0 +1,324 @@

1
//! Tab completion for the composer.
2
//!
3
//! Two things are worth completing in a coder session and neither of them is
4
//! prose: the session's own commands, and paths on this machine. Both are
5
//! finite sets that can be enumerated exactly, so a completion here is never a
6
//! guess — either the candidate exists or it is not offered.
7
//!
8
//! Tab **never inserts a candidate that was not the only one**. With several
9
//! matches it extends the word by however much they all agree on and lists
10
//! them; the next keystroke narrows the set. That is the readline behaviour,
11
//! and it means Tab cannot silently choose something you did not mean.
12
//!
13
//! [`complete`] is pure apart from reading the directory it is completing in,
14
//! which is what the tests below give it a temporary one of.
15
16
use std::path::{Path, PathBuf};
17
18
/// What Tab found.
19
#[derive(Debug, Clone, PartialEq, Eq, Default)]
20
pub struct Completion {
21
    /// Text to insert at the caret. Empty when nothing can be added.
22
    pub insert: String,
23
    /// Every candidate, when there is more than one, for the reader to see.
24
    /// Empty when the completion was unambiguous or when there was none.
25
    pub candidates: Vec<String>,
26
}
27
28
impl Completion {
29
    fn none() -> Self {
30
        Self::default()
31
    }
32
33
    pub fn is_empty(&self) -> bool {
34
        self.insert.is_empty() && self.candidates.is_empty()
35
    }
36
}
37
38
/// Complete the word ending at `caret` in `text`.
39
///
40
/// `commands` is the set of slash commands the session actually handles; it is
41
/// passed in rather than kept here so a command that is added without being
42
/// wired cannot be offered.
43
pub fn complete(text: &str, caret: usize, commands: &[&str], cwd: &Path) -> Completion {
44
    let caret = caret.min(text.len());
45
    let head = &text[..caret];
46
47
    // A slash command is only a command at the very start of the composer, and
48
    // only while its name is still being typed.
49
    if let Some(partial) = head.strip_prefix('/') {
50
        if !partial.contains(char::is_whitespace) {
51
            return from_candidates(
52
                partial,
53
                commands
54
                    .iter()
55
                    .filter(|name| name.starts_with(partial))
56
                    .map(|name| (*name).to_string())
57
                    .collect(),
58
            );
59
        }
60
    }
61
62
    let word = word_at(head);
63
    if word.is_empty() && head.ends_with(char::is_whitespace) {
64
        return Completion::none();
65
    }
66
    paths(word, cwd)
67
}
68
69
/// The word the caret is at the end of: everything back to whitespace.
70
fn word_at(head: &str) -> &str {
71
    let start = head
72
        .char_indices()
73
        .rev()
74
        .find(|(_, c)| c.is_whitespace())
75
        .map_or(0, |(index, c)| index + c.len_utf8());
76
    &head[start..]
77
}
78
79
/// Path candidates for `word`, which may be absolute, `~`-rooted, or relative.
80
fn paths(word: &str, cwd: &Path) -> Completion {
81
    // A leading `@` is how a prompt names a file to the model; it is not part
82
    // of the path being completed.
83
    let body = word.strip_prefix('@').unwrap_or(word);
84
85
    // Split into the directory to read and the stem being matched in it.
86
    let (directory, stem) = match body.rsplit_once('/') {
87
        Some(("", stem)) => (PathBuf::from("/"), stem),
88
        Some(("~", stem)) => (crate::auth::home_directory(), stem),
89
        Some((dir, stem)) => {
90
            let base = match dir.strip_prefix("~/") {
91
                Some(rest) => crate::auth::home_directory().join(rest),
92
                None if Path::new(dir).is_absolute() => PathBuf::from(dir),
93
                None => cwd.join(dir),
94
            };
95
            (base, stem)
96
        }
97
        None if body == "~" => (crate::auth::home_directory(), ""),
98
        None => (cwd.to_path_buf(), body),
99
    };
100
101
    let Ok(reading) = std::fs::read_dir(&directory) else {
102
        return Completion::none();
103
    };
104
    let mut found: Vec<String> = Vec::new();
105
    for entry in reading.flatten() {
106
        let name = entry.file_name().to_string_lossy().to_string();
107
        if !name.starts_with(stem) {
108
            continue;
109
        }
110
        // A dotfile is offered only once the reader has typed the dot, which
111
        // is what keeps a bare Tab from listing every `.git` in the tree.
112
        if name.starts_with('.') && !stem.starts_with('.') {
113
            continue;
114
        }
115
        let is_directory = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
116
        found.push(if is_directory {
117
            format!("{name}/")
118
        } else {
119
            name
120
        });
121
    }
122
    found.sort();
123
    from_candidates(stem, found)
124
}
125
126
/// Turn candidates into what Tab should do about them.
127
fn from_candidates(typed: &str, candidates: Vec<String>) -> Completion {
128
    match candidates.len() {
129
        0 => Completion::none(),
130
        1 => {
131
            let only = &candidates[0];
132
            let mut insert = only[typed.len()..].to_string();
133
            // A completed command gets its space; a completed directory has
134
            // already got its slash and is likely to be typed into further.
135
            if !only.ends_with('/') {
136
                insert.push(' ');
137
            }
138
            Completion {
139
                insert,
140
                candidates: Vec::new(),
141
            }
142
        }
143
        _ => {
144
            let shared = common_prefix(&candidates);
145
            Completion {
146
                insert: shared[typed.len().min(shared.len())..].to_string(),
147
                candidates,
148
            }
149
        }
150
    }
151
}
152
153
/// The longest prefix every candidate shares.
154
///
155
/// Built up a character at a time rather than cut down a byte at a time, so a
156
/// candidate with a multi-byte character in it cannot be split through one.
157
fn common_prefix(candidates: &[String]) -> String {
158
    let Some(first) = candidates.first() else {
159
        return String::new();
160
    };
161
    let mut shared = String::new();
162
    for (index, ch) in first.char_indices() {
163
        let end = index + ch.len_utf8();
164
        if candidates[1..]
165
            .iter()
166
            .all(|other| other.starts_with(&first[..end]))
167
        {
168
            shared.push(ch);
169
        } else {
170
            break;
171
        }
172
    }
173
    shared
174
}
175
176
#[cfg(test)]
177
mod tests {
178
    use super::*;
179
180
    const COMMANDS: &[&str] = &["clear", "diff", "export", "help", "run"];
181
182
    fn scratch() -> tempfile::TempDir {
183
        let dir = tempfile::tempdir().expect("a temporary directory");
184
        std::fs::create_dir(dir.path().join("crates")).expect("crates/");
185
        std::fs::create_dir(dir.path().join("credentials")).expect("credentials/");
186
        std::fs::write(dir.path().join("README.md"), "").expect("README.md");
187
        std::fs::write(dir.path().join("Cargo.toml"), "").expect("Cargo.toml");
188
        std::fs::write(dir.path().join(".hidden"), "").expect(".hidden");
189
        std::fs::write(dir.path().join("crates").join("one.rs"), "").expect("one.rs");
190
        dir
191
    }
192
193
    fn at(text: &str, dir: &Path) -> Completion {
194
        complete(text, text.len(), COMMANDS, dir)
195
    }
196
197
    #[test]
198
    fn one_matching_command_completes_and_adds_its_space() {
199
        let dir = scratch();
200
        assert_eq!(
201
            at("/ex", dir.path()),
202
            Completion {
203
                insert: "port ".to_string(),
204
                candidates: Vec::new()
205
            }
206
        );
207
    }
208
209
    #[test]
210
    fn several_matching_commands_are_all_listed_and_none_is_chosen() {
211
        let dir = scratch();
212
        let found = at("/", dir.path());
213
        assert_eq!(
214
            found.candidates,
215
            vec!["clear", "diff", "export", "help", "run"]
216
        );
217
        assert_eq!(
218
            found.insert, "",
219
            "Tab chose a command when five matched: {found:?}"
220
        );
221
    }
222
223
    #[test]
224
    fn a_shared_prefix_is_inserted_and_the_choice_is_left_open() {
225
        let dir = scratch();
226
        // Both directories in the scratch tree start with `cr`.
227
        let found = at("cr", dir.path());
228
        assert_eq!(found.insert, "");
229
        assert_eq!(found.candidates, vec!["crates/", "credentials/"]);
230
231
        let found = at("c", dir.path());
232
        assert_eq!(found.insert, "r", "the shared prefix was not offered");
233
    }
234
235
    #[test]
236
    fn a_command_that_matches_nothing_offers_nothing() {
237
        let dir = scratch();
238
        assert!(at("/nope", dir.path()).is_empty());
239
    }
240
241
    #[test]
242
    fn a_path_completes_inside_the_working_directory() {
243
        let dir = scratch();
244
        assert_eq!(at("REA", dir.path()).insert, "DME.md ");
245
    }
246
247
    #[test]
248
    fn a_directory_keeps_its_slash_and_gets_no_space() {
249
        let dir = scratch();
250
        let found = at("crat", dir.path());
251
        assert_eq!(found.insert, "es/");
252
        assert!(found.candidates.is_empty());
253
    }
254
255
    #[test]
256
    fn completing_continues_inside_a_directory() {
257
        let dir = scratch();
258
        assert_eq!(at("crates/o", dir.path()).insert, "ne.rs ");
259
    }
260
261
    #[test]
262
    fn a_path_argument_after_a_command_completes_as_a_path() {
263
        let dir = scratch();
264
        assert_eq!(at("/diff REA", dir.path()).insert, "DME.md ");
265
    }
266
267
    #[test]
268
    fn a_dotfile_is_offered_only_once_the_dot_is_typed() {
269
        let dir = scratch();
270
        let bare = at("", dir.path());
271
        assert!(
272
            !bare.candidates.iter().any(|name| name == ".hidden"),
273
            "{bare:?}"
274
        );
275
        assert_eq!(at(".hid", dir.path()).insert, "den ");
276
    }
277
278
    #[test]
279
    fn an_at_mention_completes_the_path_after_the_sigil() {
280
        let dir = scratch();
281
        assert_eq!(at("look at @Car", dir.path()).insert, "go.toml ");
282
    }
283
284
    #[test]
285
    fn tab_after_a_space_offers_nothing_rather_than_the_whole_directory() {
286
        let dir = scratch();
287
        assert!(at("tell me about ", dir.path()).is_empty());
288
    }
289
290
    #[test]
291
    fn a_slash_command_stops_being_completable_once_it_has_an_argument() {
292
        let dir = scratch();
293
        // `/diff cr` completes a path, not a command, even though `cr` would
294
        // match no command anyway — the point is which set was consulted.
295
        let found = at("/diff cr", dir.path());
296
        assert_eq!(found.candidates, vec!["crates/", "credentials/"]);
297
    }
298
299
    #[test]
300
    fn the_caret_is_where_completion_happens_not_the_end_of_the_line() {
301
        let dir = scratch();
302
        let text = "REA and more";
303
        let found = complete(text, 3, COMMANDS, dir.path());
304
        assert_eq!(found.insert, "DME.md ");
305
    }
306
307
    #[test]
308
    fn a_directory_that_cannot_be_read_offers_nothing() {
309
        let dir = scratch();
310
        assert!(at("no/such/place/x", dir.path()).is_empty());
311
    }
312
313
    #[test]
314
    fn the_shared_prefix_of_multibyte_names_is_cut_on_a_character() {
315
        assert_eq!(
316
            common_prefix(&["café-one".to_string(), "café-two".to_string()]),
317
            "café-"
318
        );
319
        assert_eq!(
320
            common_prefix(&["日本語".to_string(), "日本".to_string()]),
321
            "日本"
322
        );
323
    }
324
}
crates/openagents-cli/src/composer/history.rs added +308

@@ -0,0 +1,308 @@

1
//! What you typed last time, and the time before that.
2
//!
3
//! The composer starts empty every turn, which means a prompt you want to
4
//! adjust by one word has to be typed again from the beginning. This is the
5
//! ring that Up and Down walk, and the file it survives a restart in.
6
//!
7
//! Two rules keep the walk predictable.
8
//!
9
//! The **draft is not lost**. Walking back from a half-typed line and then
10
//! forward again returns that line, rather than an empty composer. The draft
11
//! is captured on the first Up of a run and restored when Down walks past the
12
//! newest entry.
13
//!
14
//! **A repeat is not recorded twice in a row.** Sending the same prompt three
15
//! times leaves one entry, so the walk is over distinct prompts.
16
17
use std::io::Write as _;
18
use std::path::{Path, PathBuf};
19
20
/// How many prompts are kept, in memory and on disk.
21
pub const CAPACITY: usize = 500;
22
23
/// A prompt that spans lines is stored on one line, with its newlines escaped,
24
/// so the file stays one-entry-per-line and can be read by anything.
25
const ESCAPED_NEWLINE: &str = "\\n";
26
27
#[derive(Debug, Default)]
28
pub struct History {
29
    /// Oldest first.
30
    entries: Vec<String>,
31
    /// How far back the reader has walked. `None` means "at the draft".
32
    walk: Option<usize>,
33
    /// What was in the composer when the walk started.
34
    draft: Option<String>,
35
    path: Option<PathBuf>,
36
}
37
38
impl History {
39
    /// An in-memory history that outlives nothing. What the tests use, and
40
    /// what a session with no writable home falls back to.
41
    pub fn new() -> Self {
42
        Self::default()
43
    }
44
45
    /// The file prompts are kept in between sessions.
46
    pub fn default_path() -> PathBuf {
47
        crate::auth::config_directory().join("coder-history")
48
    }
49
50
    /// Read `path`, keeping the last [`CAPACITY`] entries.
51
    ///
52
    /// A missing or unreadable file is an empty history, not an error: a
53
    /// session must open whether or not the reader has one.
54
    pub fn load(path: PathBuf) -> Self {
55
        let mut entries = std::fs::read_to_string(&path)
56
            .map(|text| {
57
                text.lines()
58
                    .filter(|line| !line.trim().is_empty())
59
                    .map(unescape)
60
                    .collect::<Vec<String>>()
61
            })
62
            .unwrap_or_default();
63
        if entries.len() > CAPACITY {
64
            entries.drain(..entries.len() - CAPACITY);
65
        }
66
        Self {
67
            entries,
68
            walk: None,
69
            draft: None,
70
            path: Some(path),
71
        }
72
    }
73
74
    pub fn entries(&self) -> &[String] {
75
        &self.entries
76
    }
77
78
    /// Remember a prompt that was sent, and append it to the file.
79
    pub fn record(&mut self, prompt: &str) {
80
        self.walk = None;
81
        self.draft = None;
82
        let prompt = prompt.trim_end();
83
        if prompt.is_empty() {
84
            return;
85
        }
86
        if self.entries.last().map(String::as_str) == Some(prompt) {
87
            return;
88
        }
89
        self.entries.push(prompt.to_string());
90
        if self.entries.len() > CAPACITY {
91
            let excess = self.entries.len() - CAPACITY;
92
            self.entries.drain(..excess);
93
        }
94
        if let Some(path) = &self.path {
95
            append(path, prompt);
96
        }
97
    }
98
99
    /// The previous prompt, given what is in the composer now.
100
    ///
101
    /// `None` when there is nothing further back, which is what lets the
102
    /// caller decide what Up means at the end of the ring.
103
    pub fn previous(&mut self, current: &str) -> Option<String> {
104
        if self.entries.is_empty() {
105
            return None;
106
        }
107
        let next = match self.walk {
108
            None => {
109
                self.draft = Some(current.to_string());
110
                self.entries.len() - 1
111
            }
112
            Some(0) => return None,
113
            Some(index) => index - 1,
114
        };
115
        self.walk = Some(next);
116
        self.entries.get(next).cloned()
117
    }
118
119
    /// The next prompt forward, or the draft once the walk runs out.
120
    ///
121
    /// Named `forward` rather than `next` so it cannot be mistaken for an
122
    /// iterator's: this walks a cursor the caller does not own.
123
    pub fn forward(&mut self) -> Option<String> {
124
        let index = self.walk?;
125
        if index + 1 < self.entries.len() {
126
            self.walk = Some(index + 1);
127
            return self.entries.get(index + 1).cloned();
128
        }
129
        self.walk = None;
130
        Some(self.draft.take().unwrap_or_default())
131
    }
132
133
    /// Abandon the walk. Called when the composer is edited, so the next Up
134
    /// starts again from what is now in it.
135
    pub fn stop_walking(&mut self) {
136
        self.walk = None;
137
        self.draft = None;
138
    }
139
140
    pub fn walking(&self) -> bool {
141
        self.walk.is_some()
142
    }
143
}
144
145
fn append(path: &Path, prompt: &str) {
146
    if let Some(parent) = path.parent() {
147
        let _ = std::fs::create_dir_all(parent);
148
    }
149
    // A history that cannot be written is not worth interrupting a session
150
    // for. The prompt still went out; only its memory was lost.
151
    if let Ok(mut file) = std::fs::OpenOptions::new()
152
        .create(true)
153
        .append(true)
154
        .open(path)
155
    {
156
        let _ = writeln!(file, "{}", escape(prompt));
157
    }
158
}
159
160
fn escape(prompt: &str) -> String {
161
    prompt.replace('\\', "\\\\").replace('\n', ESCAPED_NEWLINE)
162
}
163
164
fn unescape(line: &str) -> String {
165
    let mut out = String::with_capacity(line.len());
166
    let mut chars = line.chars();
167
    while let Some(ch) = chars.next() {
168
        if ch != '\\' {
169
            out.push(ch);
170
            continue;
171
        }
172
        match chars.next() {
173
            Some('n') => out.push('\n'),
174
            Some('\\') => out.push('\\'),
175
            Some(other) => {
176
                out.push('\\');
177
                out.push(other);
178
            }
179
            None => out.push('\\'),
180
        }
181
    }
182
    out
183
}
184
185
#[cfg(test)]
186
mod tests {
187
    use super::*;
188
189
    fn with(prompts: &[&str]) -> History {
190
        let mut history = History::new();
191
        for prompt in prompts {
192
            history.record(prompt);
193
        }
194
        history
195
    }
196
197
    #[test]
198
    fn up_walks_back_through_what_was_sent() {
199
        let mut history = with(&["first", "second", "third"]);
200
        assert_eq!(history.previous("").as_deref(), Some("third"));
201
        assert_eq!(history.previous("").as_deref(), Some("second"));
202
        assert_eq!(history.previous("").as_deref(), Some("first"));
203
        assert_eq!(history.previous(""), None, "the walk ran off the end");
204
    }
205
206
    #[test]
207
    fn down_walks_forward_and_gives_the_draft_back() {
208
        let mut history = with(&["first", "second"]);
209
        assert_eq!(history.previous("half typed").as_deref(), Some("second"));
210
        assert_eq!(history.previous("").as_deref(), Some("first"));
211
        assert_eq!(history.forward().as_deref(), Some("second"));
212
        assert_eq!(
213
            history.forward().as_deref(),
214
            Some("half typed"),
215
            "walking forward past the newest entry lost the draft"
216
        );
217
        assert!(!history.walking());
218
    }
219
220
    #[test]
221
    fn down_before_any_walk_does_nothing() {
222
        let mut history = with(&["only"]);
223
        assert_eq!(history.forward(), None);
224
    }
225
226
    #[test]
227
    fn an_empty_history_has_nothing_to_walk() {
228
        let mut history = History::new();
229
        assert_eq!(history.previous("draft"), None);
230
        assert!(!history.walking());
231
    }
232
233
    #[test]
234
    fn the_same_prompt_twice_running_is_recorded_once() {
235
        let history = with(&["same", "same", "other", "same"]);
236
        assert_eq!(history.entries(), ["same", "other", "same"]);
237
    }
238
239
    #[test]
240
    fn an_empty_prompt_is_not_recorded() {
241
        let history = with(&["", "   ", "real"]);
242
        assert_eq!(history.entries(), ["real"]);
243
    }
244
245
    #[test]
246
    fn editing_after_a_walk_starts_the_next_walk_from_the_new_text() {
247
        let mut history = with(&["one", "two"]);
248
        assert_eq!(history.previous("").as_deref(), Some("two"));
249
        history.stop_walking();
250
        assert_eq!(history.previous("edited").as_deref(), Some("two"));
251
        assert_eq!(history.previous("").as_deref(), Some("one"));
252
        assert_eq!(history.forward().as_deref(), Some("two"));
253
        assert_eq!(history.forward().as_deref(), Some("edited"));
254
    }
255
256
    #[test]
257
    fn a_history_survives_a_restart() {
258
        let dir = tempfile::tempdir().expect("a temporary directory");
259
        let path = dir.path().join("nested").join("coder-history");
260
261
        let mut first = History::load(path.clone());
262
        first.record("what changed today");
263
        first.record("run the tests");
264
265
        let mut second = History::load(path);
266
        assert_eq!(second.entries(), ["what changed today", "run the tests"]);
267
        assert_eq!(second.previous("").as_deref(), Some("run the tests"));
268
    }
269
270
    #[test]
271
    fn a_multi_line_prompt_comes_back_with_its_lines() {
272
        let dir = tempfile::tempdir().expect("a temporary directory");
273
        let path = dir.path().join("coder-history");
274
        History::load(path.clone()).record("first line\nsecond line");
275
        assert_eq!(
276
            History::load(path).entries(),
277
            ["first line\nsecond line".to_string()]
278
        );
279
    }
280
281
    #[test]
282
    fn a_backslash_in_a_prompt_is_not_read_back_as_a_newline() {
283
        let dir = tempfile::tempdir().expect("a temporary directory");
284
        let path = dir.path().join("coder-history");
285
        History::load(path.clone()).record("grep '\\n' src");
286
        assert_eq!(
287
            History::load(path).entries(),
288
            ["grep '\\n' src".to_string()]
289
        );
290
    }
291
292
    #[test]
293
    fn a_missing_file_is_an_empty_history_rather_than_a_failure() {
294
        let dir = tempfile::tempdir().expect("a temporary directory");
295
        let history = History::load(dir.path().join("was-never-written"));
296
        assert!(history.entries().is_empty());
297
    }
298
299
    #[test]
300
    fn only_the_last_entries_are_kept() {
301
        let mut history = History::new();
302
        for n in 0..CAPACITY + 20 {
303
            history.record(&format!("prompt {n}"));
304
        }
305
        assert_eq!(history.entries().len(), CAPACITY);
306
        assert_eq!(history.entries()[0], format!("prompt {}", 20));
307
    }
308
}
crates/openagents-cli/src/composer/mod.rs modified +54 -4

@@ -6,7 +6,9 @@

6 6
//! the composer draws itself with, vertical motion over those rows, and the
7 7
//! small dispatch that turns a key into an edit, a newline, or a submission.
8 8
9
pub mod complete;
9 10
pub mod edit;
11
pub mod history;
10 12
pub mod keys;
11 13
12 14
use std::ops::Range;

@@ -22,7 +24,13 @@ use edit::EditBuffer;

22 24
pub enum ComposerAction {
23 25
    /// The key meant nothing here; the caller may still want it.
24 26
    Ignored,
25
    /// The composer changed and the frame is stale.
27
    /// The caret moved and the text did not.
28
    ///
29
    /// Told apart from [`ComposerAction::Redraw`] because a caller walking the
30
    /// input history has to know whether the reader has started editing: a
31
    /// caret move continues the walk, and a change to the text ends it.
32
    Moved,
33
    /// The composer's text changed and the frame is stale.
26 34
    Redraw,
27 35
    /// Enter, with the text that was in the composer. The composer is now empty.
28 36
    Submit(String),

@@ -67,6 +75,17 @@ impl Composer {

67 75
        let _ = self.buffer.insert_str(text);
68 76
    }
69 77
78
    /// Replace everything in the composer, leaving the caret at the end.
79
    ///
80
    /// What walking the input history does. The caret goes to the end because
81
    /// a recalled prompt is nearly always being added to.
82
    pub fn set_text(&mut self, text: &str) {
83
        self.preferred_col = None;
84
        self.buffer = EditBuffer::from_text(text);
85
        let end = self.buffer.text().len();
86
        let _ = self.buffer.set_cursor_byte(end);
87
    }
88
70 89
    /// The rows the composer draws, soft-wrapped to `width` columns.
71 90
    pub fn rows(&self, width: usize) -> Vec<&str> {
72 91
        wrap_rows(self.text(), width)

@@ -126,8 +145,12 @@ impl Composer {

126 145
        self.preferred_col = None;
127 146
        // The key was the composer's even when the caret was already at the
128 147
        // edge it was asked to move to, so the caller does not also get it.
129
        let _ = self.buffer.apply(command);
130
        ComposerAction::Redraw
148
        let outcome = self.buffer.apply(command);
149
        if outcome.text_changed() {
150
            ComposerAction::Redraw
151
        } else {
152
            ComposerAction::Moved
153
        }
131 154
    }
132 155
133 156
    /// Move the caret one wrapped row up or down, holding the preferred column.

@@ -151,7 +174,7 @@ impl Composer {

151 174
        let row = &rows[target as usize];
152 175
        let byte = byte_at_column(self.text(), row.clone(), column);
153 176
        let _ = self.buffer.set_cursor_byte(byte);
154
        ComposerAction::Redraw
177
        ComposerAction::Moved
155 178
    }
156 179
}
157 180

@@ -404,6 +427,33 @@ mod tests {

404 427
        assert_eq!(wrap_rows("", 10), vec![0..0]);
405 428
    }
406 429
430
    /// The caller walking the input history needs these two apart, so they are
431
    /// asserted apart here rather than left to whatever the caller assumes.
432
    #[test]
433
    fn moving_the_caret_and_changing_the_text_are_different_answers() {
434
        let mut c = Composer::new();
435
        typed(&mut c, "abc");
436
        assert_eq!(c.handle_key(&key(KeyCode::Left), 40), ComposerAction::Moved);
437
        assert_eq!(
438
            c.handle_key(&key(KeyCode::Backspace), 40),
439
            ComposerAction::Redraw
440
        );
441
        // A motion that had nowhere to go is still a motion, not an edit.
442
        c.handle_key(&key(KeyCode::Home), 40);
443
        assert_eq!(c.handle_key(&key(KeyCode::Left), 40), ComposerAction::Moved);
444
    }
445
446
    #[test]
447
    fn setting_the_text_replaces_it_and_leaves_the_caret_at_the_end() {
448
        let mut c = Composer::new();
449
        typed(&mut c, "draft");
450
        c.set_text("a recalled prompt");
451
        assert_eq!(c.text(), "a recalled prompt");
452
        assert_eq!(c.cursor_rowcol(40), (0, 17));
453
        typed(&mut c, "!");
454
        assert_eq!(c.text(), "a recalled prompt!");
455
    }
456
407 457
    #[test]
408 458
    fn a_wide_grapheme_counts_for_two_columns() {
409 459
        let mut c = Composer::new();
crates/openagents-cli/src/diff.rs added +1092

@@ -0,0 +1,1092 @@

1
//! The diff inspector: what changed, side by side or unified.
2
//!
3
//! Three parts, and the split matters because two of them are pure.
4
//!
5
//! - [`compare`] computes a diff between two texts. It is Myers' algorithm
6
//!   over lines, with a bounded edit distance and a stated fallback, so a
7
//!   pathological pair cannot hang the session that called it.
8
//! - [`parse_unified`] reads a diff somebody else produced — `git diff`
9
//!   output — into the same shape. A diff the tool already knows about is
10
//!   better than one recomputed from files that have since moved on.
11
//! - [`render`] turns a [`FileDiff`] into rows. Unified and side-by-side are
12
//!   two functions over one model rather than two models.
13
//!
14
//! Nothing here touches the terminal or the filesystem, so every rendering
15
//! below is asserted in this file against the rows a reader would see.
16
17
use ratatui::style::{Color, Modifier, Style};
18
use ratatui::text::{Line, Span};
19
use unicode_width::UnicodeWidthStr as _;
20
21
use crate::markdown::truncate_spans;
22
23
/// What happened to one line.
24
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25
pub enum Tag {
26
    Equal,
27
    Delete,
28
    Insert,
29
}
30
31
/// One line of a hunk, with the line numbers it holds on each side.
32
#[derive(Clone, Debug, PartialEq, Eq)]
33
pub struct DiffLine {
34
    pub tag: Tag,
35
    /// Its number in the old file, if it is in the old file.
36
    pub old: Option<usize>,
37
    /// Its number in the new file, if it is in the new file.
38
    pub new: Option<usize>,
39
    pub text: String,
40
}
41
42
/// A run of changed lines and the context around it.
43
#[derive(Clone, Debug, PartialEq, Eq, Default)]
44
pub struct Hunk {
45
    pub old_start: usize,
46
    pub old_count: usize,
47
    pub new_start: usize,
48
    pub new_count: usize,
49
    pub lines: Vec<DiffLine>,
50
}
51
52
impl Hunk {
53
    /// The `@@ -a,b +c,d @@` header this hunk would be written with.
54
    pub fn header(&self) -> String {
55
        format!(
56
            "@@ -{},{} +{},{} @@",
57
            self.old_start, self.old_count, self.new_start, self.new_count
58
        )
59
    }
60
}
61
62
/// Every hunk for one path.
63
#[derive(Clone, Debug, PartialEq, Eq, Default)]
64
pub struct FileDiff {
65
    /// The path as it is now. A deleted file keeps the path it had.
66
    pub path: String,
67
    /// The path it had before, when a rename moved it.
68
    pub renamed_from: Option<String>,
69
    pub hunks: Vec<Hunk>,
70
    /// A file whose difference this tool will not show: a binary one, or one
71
    /// git reported without a body. Carries the reason, which is printed
72
    /// instead of an empty pane.
73
    pub note: Option<String>,
74
}
75
76
impl FileDiff {
77
    /// Lines added and lines removed.
78
    pub fn stats(&self) -> (usize, usize) {
79
        let mut added = 0;
80
        let mut removed = 0;
81
        for hunk in &self.hunks {
82
            for line in &hunk.lines {
83
                match line.tag {
84
                    Tag::Insert => added += 1,
85
                    Tag::Delete => removed += 1,
86
                    Tag::Equal => {}
87
                }
88
            }
89
        }
90
        (added, removed)
91
    }
92
93
    pub fn is_empty(&self) -> bool {
94
        self.hunks.iter().all(|hunk| hunk.lines.is_empty())
95
    }
96
}
97
98
/// How the inspector lays a diff out.
99
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100
pub enum DiffMode {
101
    Unified,
102
    SideBySide,
103
}
104
105
impl DiffMode {
106
    pub fn toggled(self) -> Self {
107
        match self {
108
            DiffMode::Unified => DiffMode::SideBySide,
109
            DiffMode::SideBySide => DiffMode::Unified,
110
        }
111
    }
112
113
    pub fn label(self) -> &'static str {
114
        match self {
115
            DiffMode::Unified => "unified",
116
            DiffMode::SideBySide => "side by side",
117
        }
118
    }
119
}
120
121
// ------------------------------------------------------------- computing one
122
123
/// Rows of context kept either side of a change.
124
pub const CONTEXT: usize = 3;
125
126
/// The largest edit distance the line diff will search before it gives up.
127
///
128
/// Myers' algorithm costs O(ND) in the edit distance, and the trace this one
129
/// keeps in order to backtrack costs O(D²) in memory. Two unrelated files are
130
/// the expensive case, and they are also the case where a line-by-line diff
131
/// tells the reader nothing: past this bound the answer is reported as a
132
/// wholesale replacement, which is true, cheap, and no less legible.
133
///
134
/// The bound is on the differing middle. Matching head and tail are trimmed
135
/// before the search, so a one-line edit in a fifty-thousand-line file is
136
/// nowhere near it.
137
const MAX_EDIT_DISTANCE: usize = 1_500;
138
139
/// Diff two texts by line.
140
pub fn compare(old: &str, new: &str, context: usize) -> Vec<Hunk> {
141
    let old_lines: Vec<&str> = split(old);
142
    let new_lines: Vec<&str> = split(new);
143
    let script = myers(&old_lines, &new_lines);
144
    hunks(script, context)
145
}
146
147
fn split(text: &str) -> Vec<&str> {
148
    if text.is_empty() {
149
        return Vec::new();
150
    }
151
    let mut lines: Vec<&str> = text.split('\n').collect();
152
    // A trailing newline ends the last line; it does not begin an empty one.
153
    if lines.last() == Some(&"") {
154
        lines.pop();
155
    }
156
    lines
157
}
158
159
/// Myers' greedy algorithm, producing one [`DiffLine`] per line of both files.
160
fn myers(old: &[&str], new: &[&str]) -> Vec<DiffLine> {
161
    let n = old.len();
162
    let m = new.len();
163
164
    // Common head and tail are not worth searching through, and trimming them
165
    // is what keeps the search small for the ordinary case of a small edit in
166
    // a large file.
167
    let head = old
168
        .iter()
169
        .zip(new.iter())
170
        .take_while(|(a, b)| a == b)
171
        .count();
172
    let tail = old[head..]
173
        .iter()
174
        .rev()
175
        .zip(new[head..].iter().rev())
176
        .take_while(|(a, b)| a == b)
177
        .count();
178
179
    let mut script = Vec::with_capacity(n.max(m));
180
    for (index, line) in old[..head].iter().enumerate() {
181
        script.push(DiffLine {
182
            tag: Tag::Equal,
183
            old: Some(index + 1),
184
            new: Some(index + 1),
185
            text: (*line).to_string(),
186
        });
187
    }
188
189
    let middle_old = &old[head..n - tail];
190
    let middle_new = &new[head..m - tail];
191
    let middle = match trace(middle_old, middle_new) {
192
        Some(path) => walk(path, middle_old, middle_new, head),
193
        // Past the bound, or two texts with nothing in common: everything
194
        // that was there went, and everything that is there arrived.
195
        None => wholesale(middle_old, middle_new, head),
196
    };
197
    script.extend(middle);
198
199
    for offset in 0..tail {
200
        script.push(DiffLine {
201
            tag: Tag::Equal,
202
            old: Some(n - tail + offset + 1),
203
            new: Some(m - tail + offset + 1),
204
            text: old[n - tail + offset].to_string(),
205
        });
206
    }
207
    script
208
}
209
210
fn wholesale(old: &[&str], new: &[&str], head: usize) -> Vec<DiffLine> {
211
    let mut out = Vec::with_capacity(old.len() + new.len());
212
    for (index, line) in old.iter().enumerate() {
213
        out.push(DiffLine {
214
            tag: Tag::Delete,
215
            old: Some(head + index + 1),
216
            new: None,
217
            text: (*line).to_string(),
218
        });
219
    }
220
    for (index, line) in new.iter().enumerate() {
221
        out.push(DiffLine {
222
            tag: Tag::Insert,
223
            old: None,
224
            new: Some(head + index + 1),
225
            text: (*line).to_string(),
226
        });
227
    }
228
    out
229
}
230
231
/// The furthest-reaching endpoint on each diagonal, for each edit distance.
232
///
233
/// A *diagonal* `k` is the set of points where `x - y == k`. `trace[d]` is the
234
/// frontier as it stood *before* the `d`-th step: for each diagonal, how far
235
/// along the old file the best path had reached. [`walk`] reads it backwards
236
/// to recover the edits.
237
///
238
/// Each row holds only the diagonals that step can touch, `-(d+1)..=(d+1)`,
239
/// indexed by [`slot`]. That keeps the trace quadratic in the edit distance
240
/// rather than proportional to the size of the files, which for a small edit
241
/// in a large file is the whole difference.
242
///
243
/// `None` means the bound was reached, which the caller answers by reporting
244
/// a wholesale replacement rather than by searching on.
245
fn trace(old: &[&str], new: &[&str]) -> Option<Vec<Vec<usize>>> {
246
    let n = old.len();
247
    let m = new.len();
248
    let max = n + m;
249
    if max == 0 {
250
        return Some(Vec::new());
251
    }
252
253
    // One slot per diagonal from `-(max+1)` to `max+1`, so the neighbours of
254
    // the outermost diagonal are addressable without a bounds check.
255
    let mut frontier = vec![0usize; 2 * max + 3];
256
    let width = |d: isize| (max as isize + 1 - (d + 1)) as usize..(max + 1 + (d as usize + 1)) + 1;
257
    let mut trace: Vec<Vec<usize>> = Vec::new();
258
259
    for d in 0..=max.min(MAX_EDIT_DISTANCE) as isize {
260
        trace.push(frontier[width(d)].to_vec());
261
        let mut k = -d;
262
        while k <= d {
263
            // Take whichever neighbour reaches further: down from `k + 1` is
264
            // an insertion, right from `k - 1` is a deletion. At the edges of
265
            // the frontier only one of the two exists.
266
            let down =
267
                k == -d || (k != d && frontier[offset(k - 1, max)] < frontier[offset(k + 1, max)]);
268
            let mut x = if down {
269
                frontier[offset(k + 1, max)]
270
            } else {
271
                frontier[offset(k - 1, max)] + 1
272
            };
273
            let mut y = (x as isize - k) as usize;
274
            while x < n && y < m && old[x] == new[y] {
275
                x += 1;
276
                y += 1;
277
            }
278
            frontier[offset(k, max)] = x;
279
            if x >= n && y >= m {
280
                return Some(trace);
281
            }
282
            k += 2;
283
        }
284
    }
285
    None
286
}
287
288
/// Where diagonal `k` lives in the whole frontier.
289
fn offset(k: isize, max: usize) -> usize {
290
    (k + max as isize + 1) as usize
291
}
292
293
/// Where diagonal `k` lives in `trace[d]`, which holds only `-(d+1)..=(d+1)`.
294
fn slot(k: isize, d: isize) -> usize {
295
    (k + d + 1) as usize
296
}
297
298
/// Backtrack the trace into one entry per line of both files.
299
fn walk(trace: Vec<Vec<usize>>, old: &[&str], new: &[&str], head: usize) -> Vec<DiffLine> {
300
    let n = old.len();
301
    let m = new.len();
302
    let mut out: Vec<DiffLine> = Vec::new();
303
    if n + m == 0 {
304
        return out;
305
    }
306
307
    let mut x = n;
308
    let mut y = m;
309
    for d in (0..trace.len()).rev() {
310
        let v = &trace[d];
311
        let d = d as isize;
312
        let k = x as isize - y as isize;
313
        let down = k == -d || (k != d && v[slot(k - 1, d)] < v[slot(k + 1, d)]);
314
        let previous_k = if down { k + 1 } else { k - 1 };
315
        let previous_x = v[slot(previous_k, d)];
316
        let previous_y = (previous_x as isize - previous_k) as usize;
317
318
        // The run of matching lines this step ended on.
319
        while x > previous_x && y > previous_y {
320
            x -= 1;
321
            y -= 1;
322
            out.push(DiffLine {
323
                tag: Tag::Equal,
324
                old: Some(head + x + 1),
325
                new: Some(head + y + 1),
326
                text: old[x].to_string(),
327
            });
328
        }
329
        if d == 0 {
330
            break;
331
        }
332
        // Then the single edit that got there.
333
        if x > previous_x {
334
            x -= 1;
335
            out.push(DiffLine {
336
                tag: Tag::Delete,
337
                old: Some(head + x + 1),
338
                new: None,
339
                text: old[x].to_string(),
340
            });
341
        } else if y > previous_y {
342
            y -= 1;
343
            out.push(DiffLine {
344
                tag: Tag::Insert,
345
                old: None,
346
                new: Some(head + y + 1),
347
                text: new[y].to_string(),
348
            });
349
        }
350
        x = previous_x;
351
        y = previous_y;
352
    }
353
    out.reverse();
354
    out
355
}
356
357
/// Group a full edit script into hunks with `context` lines either side.
358
fn hunks(script: Vec<DiffLine>, context: usize) -> Vec<Hunk> {
359
    let changed: Vec<usize> = script
360
        .iter()
361
        .enumerate()
362
        .filter(|(_, line)| line.tag != Tag::Equal)
363
        .map(|(index, _)| index)
364
        .collect();
365
    if changed.is_empty() {
366
        return Vec::new();
367
    }
368
369
    // Walk the changed indices, starting a new hunk whenever the gap between
370
    // two changes is wider than twice the context — any narrower and the two
371
    // runs of context would touch, and one hunk reads better than two.
372
    let mut spans: Vec<(usize, usize)> = Vec::new();
373
    let mut start = changed[0];
374
    let mut end = changed[0];
375
    for index in changed.into_iter().skip(1) {
376
        if index - end > context * 2 + 1 {
377
            spans.push((start, end));
378
            start = index;
379
        }
380
        end = index;
381
    }
382
    spans.push((start, end));
383
384
    spans
385
        .into_iter()
386
        .map(|(start, end)| {
387
            let from = start.saturating_sub(context);
388
            let to = (end + context + 1).min(script.len());
389
            let lines: Vec<DiffLine> = script[from..to].to_vec();
390
            let old_count = lines.iter().filter(|l| l.tag != Tag::Insert).count();
391
            let new_count = lines.iter().filter(|l| l.tag != Tag::Delete).count();
392
            Hunk {
393
                old_start: lines.iter().find_map(|l| l.old).unwrap_or(0),
394
                old_count,
395
                new_start: lines.iter().find_map(|l| l.new).unwrap_or(0),
396
                new_count,
397
                lines,
398
            }
399
        })
400
        .collect()
401
}
402
403
// ------------------------------------------------------------------ parsing
404
405
/// Read `git diff` output into file diffs.
406
///
407
/// Every line number in the result comes from the `@@` headers, so a hunk that
408
/// git elided context from still reports the file's own numbering.
409
pub fn parse_unified(text: &str) -> Vec<FileDiff> {
410
    let mut files: Vec<FileDiff> = Vec::new();
411
    let mut old_line = 0usize;
412
    let mut new_line = 0usize;
413
414
    for raw in text.split('\n') {
415
        if let Some(rest) = raw.strip_prefix("diff --git ") {
416
            files.push(FileDiff {
417
                path: git_paths(rest).map_or_else(|| rest.to_string(), |(_, b)| b),
418
                ..FileDiff::default()
419
            });
420
            continue;
421
        }
422
        let Some(file) = files.last_mut() else {
423
            continue;
424
        };
425
        if let Some(rest) = raw.strip_prefix("rename from ") {
426
            file.renamed_from = Some(rest.to_string());
427
            continue;
428
        }
429
        if raw.starts_with("Binary files ") || raw.starts_with("GIT binary patch") {
430
            file.note = Some("Binary file. Nothing to show line by line.".to_string());
431
            continue;
432
        }
433
        if let Some(rest) = raw.strip_prefix("+++ b/") {
434
            file.path = rest.to_string();
435
            continue;
436
        }
437
        if raw.starts_with("@@") {
438
            if let Some((os, oc, ns, nc)) = hunk_header(raw) {
439
                old_line = os;
440
                new_line = ns;
441
                file.hunks.push(Hunk {
442
                    old_start: os,
443
                    old_count: oc,
444
                    new_start: ns,
445
                    new_count: nc,
446
                    lines: Vec::new(),
447
                });
448
            }
449
            continue;
450
        }
451
        let Some(hunk) = file.hunks.last_mut() else {
452
            continue;
453
        };
454
        // `\ No newline at end of file` annotates the line before it; it is
455
        // not a line of either side.
456
        if raw.starts_with('\\') {
457
            continue;
458
        }
459
        match raw.chars().next() {
460
            Some('+') => {
461
                hunk.lines.push(DiffLine {
462
                    tag: Tag::Insert,
463
                    old: None,
464
                    new: Some(new_line),
465
                    text: raw[1..].to_string(),
466
                });
467
                new_line += 1;
468
            }
469
            Some('-') => {
470
                hunk.lines.push(DiffLine {
471
                    tag: Tag::Delete,
472
                    old: Some(old_line),
473
                    new: None,
474
                    text: raw[1..].to_string(),
475
                });
476
                old_line += 1;
477
            }
478
            Some(' ') => {
479
                hunk.lines.push(DiffLine {
480
                    tag: Tag::Equal,
481
                    old: Some(old_line),
482
                    new: Some(new_line),
483
                    text: raw[1..].to_string(),
484
                });
485
                old_line += 1;
486
                new_line += 1;
487
            }
488
            // An empty line inside a hunk is an unchanged empty line whose
489
            // leading space some tools trim.
490
            None => hunk.lines.push(DiffLine {
491
                tag: Tag::Equal,
492
                old: Some(old_line),
493
                new: Some(new_line),
494
                text: String::new(),
495
            }),
496
            _ => {}
497
        }
498
        if raw.is_empty() {
499
            old_line += 1;
500
            new_line += 1;
501
        }
502
    }
503
    files
504
}
505
506
/// `a/path b/path` from a `diff --git` line, as (old, new).
507
fn git_paths(rest: &str) -> Option<(String, String)> {
508
    let (a, b) = rest.split_once(" b/")?;
509
    let a = a.strip_prefix("a/")?;
510
    Some((a.to_string(), b.to_string()))
511
}
512
513
/// `@@ -12,7 +12,9 @@` as its four numbers. A count is 1 when it is omitted.
514
fn hunk_header(raw: &str) -> Option<(usize, usize, usize, usize)> {
515
    let body = raw.strip_prefix("@@ ")?;
516
    let body = body.split(" @@").next()?;
517
    let (old, new) = body.split_once(' ')?;
518
    let old = pair(old.strip_prefix('-')?)?;
519
    let new = pair(new.strip_prefix('+')?)?;
520
    Some((old.0, old.1, new.0, new.1))
521
}
522
523
fn pair(text: &str) -> Option<(usize, usize)> {
524
    match text.split_once(',') {
525
        Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
526
        None => Some((text.parse().ok()?, 1)),
527
    }
528
}
529
530
// ---------------------------------------------------------------- rendering
531
532
fn added_style() -> Style {
533
    Style::default().fg(Color::Green)
534
}
535
fn removed_style() -> Style {
536
    Style::default().fg(Color::Red)
537
}
538
fn number_style() -> Style {
539
    Style::default().fg(Color::DarkGray)
540
}
541
fn rule_style() -> Style {
542
    Style::default().fg(Color::DarkGray)
543
}
544
545
/// The narrowest body either column of the side-by-side view is worth having.
546
///
547
/// Below this the two columns show an ellipsis each, which says less than one
548
/// column of real text, so the view falls back to unified and says so.
549
const MIN_SIDE_BY_SIDE_BODY: usize = 8;
550
551
/// Every row of a file's diff, in the mode asked for.
552
pub fn render(file: &FileDiff, mode: DiffMode, width: usize) -> Vec<Line<'static>> {
553
    let width = width.max(8);
554
    let mut rows = vec![header_row(file)];
555
556
    if let Some(note) = &file.note {
557
        rows.push(Line::from(Span::styled(
558
            note.clone(),
559
            Style::default().fg(Color::Yellow),
560
        )));
561
        return rows;
562
    }
563
    if file.is_empty() {
564
        rows.push(Line::from(Span::styled(
565
            "No line changes.".to_string(),
566
            number_style(),
567
        )));
568
        return rows;
569
    }
570
571
    // One number width for the whole file, so the columns line up across
572
    // hunks rather than shifting at every `@@`.
573
    let numbers = file.hunks.iter().map(number_width).max().unwrap_or(2);
574
    let mode = match mode {
575
        DiffMode::SideBySide if side_by_side_body(width, numbers) < MIN_SIDE_BY_SIDE_BODY => {
576
            rows.push(Line::from(Span::styled(
577
                "Too narrow for two columns. Showing the unified view.".to_string(),
578
                Style::default().fg(Color::Yellow),
579
            )));
580
            DiffMode::Unified
581
        }
582
        mode => mode,
583
    };
584
585
    for hunk in &file.hunks {
586
        rows.push(Line::from(Span::styled(hunk.header(), rule_style())));
587
        match mode {
588
            DiffMode::Unified => rows.extend(unified_rows(hunk, numbers, width)),
589
            DiffMode::SideBySide => rows.extend(side_by_side_rows(hunk, numbers, width)),
590
        }
591
    }
592
593
    // The last word on the width. The body rows are already cut to fit, but a
594
    // long path in the header or a note wider than the pane would otherwise be
595
    // truncated by the renderer without an ellipsis to say so.
596
    rows.into_iter()
597
        .map(|row| Line::from(truncate_spans(row.spans, width)))
598
        .collect()
599
}
600
601
fn header_row(file: &FileDiff) -> Line<'static> {
602
    let (added, removed) = file.stats();
603
    let mut spans = vec![Span::styled(
604
        file.path.clone(),
605
        Style::default()
606
            .fg(Color::Cyan)
607
            .add_modifier(Modifier::BOLD),
608
    )];
609
    if let Some(from) = &file.renamed_from {
610
        spans.push(Span::styled(format!("  ← {from}"), number_style()));
611
    }
612
    spans.push(Span::styled("  +".to_string(), added_style()));
613
    spans.push(Span::styled(added.to_string(), added_style()));
614
    spans.push(Span::styled(" −".to_string(), removed_style()));
615
    spans.push(Span::styled(removed.to_string(), removed_style()));
616
    Line::from(spans)
617
}
618
619
/// How many columns a line number takes, given the largest one in the hunk.
620
fn number_width(hunk: &Hunk) -> usize {
621
    let largest = hunk
622
        .lines
623
        .iter()
624
        .filter_map(|line| line.old.max(line.new))
625
        .max()
626
        .unwrap_or(0);
627
    largest.to_string().len().max(2)
628
}
629
630
fn number(value: Option<usize>, width: usize) -> String {
631
    match value {
632
        Some(n) => format!("{n:>width$}"),
633
        None => " ".repeat(width),
634
    }
635
}
636
637
fn sign(tag: Tag) -> (&'static str, Style) {
638
    match tag {
639
        Tag::Insert => ("+", added_style()),
640
        Tag::Delete => ("−", removed_style()),
641
        Tag::Equal => (" ", Style::default()),
642
    }
643
}
644
645
/// `12  13 │+ the line`
646
fn unified_rows(hunk: &Hunk, numbers: usize, width: usize) -> Vec<Line<'static>> {
647
    // Two numbers, a space between them, the rule, the sign, and a space.
648
    let gutter = numbers * 2 + 1 + 2 + 2;
649
    let body = width.saturating_sub(gutter).max(4);
650
651
    hunk.lines
652
        .iter()
653
        .map(|line| {
654
            let (mark, style) = sign(line.tag);
655
            let mut spans = vec![
656
                Span::styled(
657
                    format!(
658
                        "{} {}",
659
                        number(line.old, numbers),
660
                        number(line.new, numbers)
661
                    ),
662
                    number_style(),
663
                ),
664
                Span::styled(" │".to_string(), rule_style()),
665
                Span::styled(format!("{mark} "), style),
666
            ];
667
            spans.extend(truncate_spans(
668
                vec![Span::styled(expand_tabs(&line.text), style)],
669
                body,
670
            ));
671
            Line::from(spans)
672
        })
673
        .collect()
674
}
675
676
/// One row of the side-by-side layout: the old line and the new one.
677
///
678
/// A run of removals and the run of additions that replaced it are zipped, so
679
/// a changed line sits opposite the line it changed from. A run with no
680
/// opposite number leaves that side blank.
681
fn pair_rows(hunk: &Hunk) -> Vec<(Option<&DiffLine>, Option<&DiffLine>)> {
682
    let mut rows = Vec::new();
683
    let mut removed: Vec<&DiffLine> = Vec::new();
684
    let mut added: Vec<&DiffLine> = Vec::new();
685
686
    fn flush<'a>(
687
        removed: &mut Vec<&'a DiffLine>,
688
        added: &mut Vec<&'a DiffLine>,
689
        rows: &mut Vec<(Option<&'a DiffLine>, Option<&'a DiffLine>)>,
690
    ) {
691
        for index in 0..removed.len().max(added.len()) {
692
            rows.push((removed.get(index).copied(), added.get(index).copied()));
693
        }
694
        removed.clear();
695
        added.clear();
696
    }
697
698
    for line in &hunk.lines {
699
        match line.tag {
700
            Tag::Delete => removed.push(line),
701
            Tag::Insert => added.push(line),
702
            Tag::Equal => {
703
                flush(&mut removed, &mut added, &mut rows);
704
                rows.push((Some(line), Some(line)));
705
            }
706
        }
707
    }
708
    flush(&mut removed, &mut added, &mut rows);
709
    rows
710
}
711
712
/// How many columns of text each side of the split view gets.
713
///
714
/// Each side spends `numbers` on its line number, two on the rule, and two on
715
/// the sign; the two sides are separated by a rule of their own.
716
fn side_by_side_body(width: usize, numbers: usize) -> usize {
717
    (width.saturating_sub(3) / 2).saturating_sub(numbers + 4)
718
}
719
720
fn side_by_side_rows(hunk: &Hunk, numbers: usize, width: usize) -> Vec<Line<'static>> {
721
    let body = side_by_side_body(width, numbers);
722
723
    pair_rows(hunk)
724
        .into_iter()
725
        .map(|(old, new)| {
726
            let mut spans = column(old, |line| line.old, numbers, body);
727
            spans.push(Span::styled(" │ ".to_string(), rule_style()));
728
            spans.extend(column(new, |line| line.new, numbers, body));
729
            Line::from(spans)
730
        })
731
        .collect()
732
}
733
734
/// One side of a side-by-side row, padded to a fixed width so the divider
735
/// between the two columns stays in one place down the whole pane.
736
fn column(
737
    line: Option<&DiffLine>,
738
    pick: fn(&DiffLine) -> Option<usize>,
739
    numbers: usize,
740
    body: usize,
741
) -> Vec<Span<'static>> {
742
    let Some(line) = line else {
743
        return vec![Span::raw(" ".repeat(numbers + 2 + 2 + body))];
744
    };
745
    let (mark, style) = sign(line.tag);
746
    let text = expand_tabs(&line.text);
747
    let drawn = text.width().min(body);
748
    let mut spans = vec![
749
        Span::styled(number(pick(line), numbers), number_style()),
750
        Span::styled(" │".to_string(), rule_style()),
751
        Span::styled(format!("{mark} "), style),
752
    ];
753
    spans.extend(truncate_spans(vec![Span::styled(text, style)], body));
754
    if drawn < body {
755
        spans.push(Span::raw(" ".repeat(body - drawn)));
756
    }
757
    spans
758
}
759
760
/// Tabs are not a width the renderer can reason about, so they become spaces.
761
fn expand_tabs(text: &str) -> String {
762
    text.replace('\t', "    ")
763
}
764
765
#[cfg(test)]
766
mod tests {
767
    use super::*;
768
769
    fn text(line: &Line<'_>) -> String {
770
        line.spans.iter().map(|s| s.content.as_ref()).collect()
771
    }
772
773
    fn texts(rows: &[Line<'_>]) -> Vec<String> {
774
        rows.iter().map(text).collect()
775
    }
776
777
    fn tags(hunks: &[Hunk]) -> Vec<(Tag, String)> {
778
        hunks
779
            .iter()
780
            .flat_map(|h| h.lines.iter())
781
            .map(|l| (l.tag, l.text.clone()))
782
            .collect()
783
    }
784
785
    // ------------------------------------------------------------ computing
786
787
    #[test]
788
    fn identical_texts_have_no_hunks() {
789
        assert!(compare("a\nb\nc\n", "a\nb\nc\n", CONTEXT).is_empty());
790
    }
791
792
    #[test]
793
    fn one_changed_line_is_one_delete_and_one_insert() {
794
        let hunks = compare("a\nb\nc\n", "a\nB\nc\n", CONTEXT);
795
        assert_eq!(
796
            tags(&hunks),
797
            vec![
798
                (Tag::Equal, "a".to_string()),
799
                (Tag::Delete, "b".to_string()),
800
                (Tag::Insert, "B".to_string()),
801
                (Tag::Equal, "c".to_string()),
802
            ]
803
        );
804
    }
805
806
    #[test]
807
    fn line_numbers_are_each_sides_own() {
808
        let hunks = compare("a\nb\n", "a\nx\ny\nb\n", CONTEXT);
809
        let lines = &hunks[0].lines;
810
        let inserted: Vec<_> = lines
811
            .iter()
812
            .filter(|l| l.tag == Tag::Insert)
813
            .map(|l| (l.new, l.old))
814
            .collect();
815
        assert_eq!(inserted, vec![(Some(2), None), (Some(3), None)]);
816
        let last = lines.last().expect("a trailing context line");
817
        assert_eq!((last.old, last.new), (Some(2), Some(4)));
818
    }
819
820
    #[test]
821
    fn an_insertion_at_the_top_keeps_the_lines_below_it() {
822
        let hunks = compare("b\nc\n", "a\nb\nc\n", CONTEXT);
823
        assert_eq!(
824
            tags(&hunks),
825
            vec![
826
                (Tag::Insert, "a".to_string()),
827
                (Tag::Equal, "b".to_string()),
828
                (Tag::Equal, "c".to_string()),
829
            ]
830
        );
831
    }
832
833
    #[test]
834
    fn a_deletion_at_the_end_is_a_deletion_not_a_rewrite() {
835
        let hunks = compare("a\nb\nc\n", "a\nb\n", CONTEXT);
836
        assert_eq!(
837
            tags(&hunks),
838
            vec![
839
                (Tag::Equal, "a".to_string()),
840
                (Tag::Equal, "b".to_string()),
841
                (Tag::Delete, "c".to_string()),
842
            ]
843
        );
844
    }
845
846
    /// The property that matters more than any single shape: applying the
847
    /// script to the old text has to produce the new text exactly.
848
    #[test]
849
    fn the_script_turns_the_old_text_into_the_new_one() {
850
        let twenty = "x\n".repeat(20);
851
        let nineteen = "x\n".repeat(19);
852
        let cases = [
853
            ("", "a\nb\n"),
854
            ("a\nb\n", ""),
855
            ("a\nb\nc\nd\ne\n", "a\nc\nb\nd\nx\n"),
856
            ("one\ntwo\nthree\n", "one\nTWO\nthree\nfour\n"),
857
            (twenty.as_str(), nineteen.as_str()),
858
            (
859
                "alpha\nbeta\ngamma\ndelta\n",
860
                "gamma\ndelta\nalpha\nbeta\nepsilon\n",
861
            ),
862
        ];
863
        for (old, new) in cases {
864
            // Rebuild the new file from the whole script, not from the hunks:
865
            // hunks drop unchanged runs, which is the point of them.
866
            let script = myers(&split(old), &split(new));
867
            let rebuilt: Vec<&str> = script
868
                .iter()
869
                .filter(|line| line.tag != Tag::Delete)
870
                .map(|line| line.text.as_str())
871
                .collect();
872
            assert_eq!(rebuilt, split(new), "old={old:?} new={new:?}");
873
874
            let kept: Vec<&str> = script
875
                .iter()
876
                .filter(|line| line.tag != Tag::Insert)
877
                .map(|line| line.text.as_str())
878
                .collect();
879
            assert_eq!(kept, split(old), "old={old:?} new={new:?}");
880
        }
881
    }
882
883
    #[test]
884
    fn far_apart_changes_get_a_hunk_each_and_near_ones_share() {
885
        let old: String = (1..=40).map(|n| format!("line {n}\n")).collect();
886
        let mut changed: Vec<String> = (1..=40).map(|n| format!("line {n}")).collect();
887
        changed[2] = "changed near the top".to_string();
888
        changed[35] = "changed near the bottom".to_string();
889
        let new = format!("{}\n", changed.join("\n"));
890
        assert_eq!(compare(&old, &new, CONTEXT).len(), 2);
891
892
        let mut close: Vec<String> = (1..=40).map(|n| format!("line {n}")).collect();
893
        close[2] = "one".to_string();
894
        close[5] = "two".to_string();
895
        let near = format!("{}\n", close.join("\n"));
896
        assert_eq!(compare(&old, &near, CONTEXT).len(), 1);
897
    }
898
899
    #[test]
900
    fn context_is_kept_either_side_of_a_change() {
901
        let old: String = (1..=20).map(|n| format!("line {n}\n")).collect();
902
        let new = old.replace("line 10\n", "LINE TEN\n");
903
        let hunks = compare(&old, &new, CONTEXT);
904
        assert_eq!(hunks.len(), 1);
905
        let hunk = &hunks[0];
906
        assert_eq!(hunk.old_start, 7, "{:?}", hunk.lines);
907
        assert_eq!(hunk.lines.first().map(|l| l.text.as_str()), Some("line 7"));
908
        assert_eq!(hunk.lines.last().map(|l| l.text.as_str()), Some("line 13"));
909
    }
910
911
    // -------------------------------------------------------------- parsing
912
913
    const GIT_OUTPUT: &str = "\
914
diff --git a/lib/thing.ex b/lib/thing.ex
915
index 1111111..2222222 100644
916
--- a/lib/thing.ex
917
+++ b/lib/thing.ex
918
@@ -12,6 +12,7 @@ defmodule Thing do
919
   def run do
920
     :ok
921
   end
922
+  def extra, do: :new
923
924
   def other do
925
-    :old
926
+    :changed
927
   end
928
";
929
930
    #[test]
931
    fn git_output_parses_into_one_file_with_its_own_line_numbers() {
932
        let files = parse_unified(GIT_OUTPUT);
933
        assert_eq!(files.len(), 1);
934
        assert_eq!(files[0].path, "lib/thing.ex");
935
        assert_eq!(files[0].stats(), (2, 1));
936
937
        let inserted: Vec<_> = files[0].hunks[0]
938
            .lines
939
            .iter()
940
            .filter(|l| l.tag == Tag::Insert)
941
            .map(|l| (l.new, l.text.clone()))
942
            .collect();
943
        assert_eq!(
944
            inserted,
945
            vec![
946
                (Some(15), "  def extra, do: :new".to_string()),
947
                (Some(18), "    :changed".to_string()),
948
            ]
949
        );
950
    }
951
952
    #[test]
953
    fn two_files_in_one_diff_stay_two_files() {
954
        let both = format!(
955
            "{GIT_OUTPUT}diff --git a/README.md b/README.md\n\
956
             --- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-old\n+new\n"
957
        );
958
        let files = parse_unified(&both);
959
        assert_eq!(
960
            files.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(),
961
            vec!["lib/thing.ex", "README.md"]
962
        );
963
        assert_eq!(files[1].stats(), (1, 1));
964
    }
965
966
    #[test]
967
    fn a_binary_file_says_so_rather_than_showing_an_empty_pane() {
968
        let files = parse_unified(
969
            "diff --git a/logo.png b/logo.png\nBinary files a/logo.png and b/logo.png differ\n",
970
        );
971
        let rows = render(&files[0], DiffMode::Unified, 60);
972
        assert!(
973
            texts(&rows).iter().any(|row| row.contains("Binary file")),
974
            "{:?}",
975
            texts(&rows)
976
        );
977
    }
978
979
    #[test]
980
    fn a_rename_reports_the_path_it_came_from() {
981
        let files = parse_unified(
982
            "diff --git a/old/name.rs b/new/name.rs\nsimilarity index 98%\n\
983
             rename from old/name.rs\nrename to new/name.rs\n",
984
        );
985
        assert_eq!(files[0].path, "new/name.rs");
986
        assert_eq!(files[0].renamed_from.as_deref(), Some("old/name.rs"));
987
        assert!(text(&render(&files[0], DiffMode::Unified, 60)[0]).contains("old/name.rs"));
988
    }
989
990
    // ------------------------------------------------------------ rendering
991
992
    fn one_change() -> FileDiff {
993
        FileDiff {
994
            path: "src/lib.rs".to_string(),
995
            hunks: compare("keep\nold line\ntail\n", "keep\nnew line\ntail\n", CONTEXT),
996
            ..FileDiff::default()
997
        }
998
    }
999
1000
    #[test]
1001
    fn the_unified_view_shows_both_numbers_a_sign_and_the_text() {
1002
        let rows = render(&one_change(), DiffMode::Unified, 46);
1003
        let drawn = texts(&rows);
1004
        assert_eq!(drawn[0], "src/lib.rs  +1 −1");
1005
        assert_eq!(drawn[1], "@@ -1,3 +1,3 @@");
1006
        assert_eq!(drawn[2], " 1  1 │  keep");
1007
        assert_eq!(drawn[3], " 2    │− old line");
1008
        assert_eq!(drawn[4], "    2 │+ new line");
1009
        assert_eq!(drawn[5], " 3  3 │  tail");
1010
    }
1011
1012
    #[test]
1013
    fn the_side_by_side_view_puts_the_change_opposite_what_it_replaced() {
1014
        let rows = render(&one_change(), DiffMode::SideBySide, 46);
1015
        let drawn = texts(&rows);
1016
        assert_eq!(drawn[2], " 1 │  keep            │  1 │  keep           ");
1017
        assert_eq!(drawn[3], " 2 │− old line        │  2 │+ new line       ");
1018
        assert_eq!(drawn[4], " 3 │  tail            │  3 │  tail           ");
1019
    }
1020
1021
    /// A run of three removals replaced by one addition leaves two rows with
1022
    /// nothing on the right, rather than sliding the rest of the file up.
1023
    #[test]
1024
    fn an_unequal_run_leaves_the_short_side_blank() {
1025
        let file = FileDiff {
1026
            path: "f".to_string(),
1027
            hunks: compare("a\nb\nc\nd\n", "a\nZ\nd\n", CONTEXT),
1028
            ..FileDiff::default()
1029
        };
1030
        let rows = texts(&render(&file, DiffMode::SideBySide, 40));
1031
        // Row 0 is the header, row 1 the hunk header, row 2 the `a` context.
1032
        assert!(rows[3].contains('b') && rows[3].contains('Z'), "{rows:?}");
1033
        assert!(rows[4].contains('c'), "{rows:?}");
1034
        assert!(!rows[4].contains('Z'), "{rows:?}");
1035
        assert!(
1036
            rows[4].ends_with("   "),
1037
            "the right column was not left blank: {:?}",
1038
            rows[4]
1039
        );
1040
    }
1041
1042
    #[test]
1043
    fn no_rendered_row_is_wider_than_the_pane() {
1044
        let long = format!("{}\n", "x".repeat(400));
1045
        let file = FileDiff {
1046
            path: "wide".to_string(),
1047
            hunks: compare(
1048
                &format!("a\n{long}b\n"),
1049
                &format!("a\n{}\nb\n", "y".repeat(400)),
1050
                CONTEXT,
1051
            ),
1052
            ..FileDiff::default()
1053
        };
1054
        for mode in [DiffMode::Unified, DiffMode::SideBySide] {
1055
            for width in [20usize, 33, 60, 120] {
1056
                for row in render(&file, mode, width) {
1057
                    let drawn: usize = row.spans.iter().map(|s| s.content.width()).sum();
1058
                    assert!(
1059
                        drawn <= width,
1060
                        "{mode:?} drew {drawn} columns into {width}: {:?}",
1061
                        text(&row)
1062
                    );
1063
                }
1064
            }
1065
        }
1066
    }
1067
1068
    #[test]
1069
    fn additions_are_green_and_removals_are_red() {
1070
        let rows = render(&one_change(), DiffMode::Unified, 46);
1071
        let removed = rows[3]
1072
            .spans
1073
            .iter()
1074
            .find(|s| s.content.contains("old line"))
1075
            .and_then(|s| s.style.fg);
1076
        let added = rows[4]
1077
            .spans
1078
            .iter()
1079
            .find(|s| s.content.contains("new line"))
1080
            .and_then(|s| s.style.fg);
1081
        assert_eq!(removed, Some(Color::Red));
1082
        assert_eq!(added, Some(Color::Green));
1083
    }
1084
1085
    #[test]
1086
    fn the_mode_toggles_both_ways_and_names_itself() {
1087
        assert_eq!(DiffMode::Unified.toggled(), DiffMode::SideBySide);
1088
        assert_eq!(DiffMode::SideBySide.toggled(), DiffMode::Unified);
1089
        assert_eq!(DiffMode::Unified.label(), "unified");
1090
        assert_eq!(DiffMode::SideBySide.label(), "side by side");
1091
    }
1092
}
crates/openagents-cli/src/interactive.rs modified +685 -38

@@ -8,8 +8,9 @@

8 8
//! - [`run_loop`] joins that state machine to a stream of terminal events and
9 9
//!   a channel of turn events. It is generic over both, so the loop a test
10 10
//!   runs is the loop production runs.
11
//! - [`runtime_actor`] owns the [`CoderRuntimeSession`] and does the turns.
12
//!   It is a task rather than a call inside the loop because
11
//! - [`runtime_actor`] owns the [`CoderRuntimeSession`] and does the work the
12
//!   app asks for: turns, diffs, and starting programs under a
13
//!   pseudoterminal. It is a task rather than a call inside the loop because
13 14
//!   `execute_turn` borrows the session for the length of a turn, and the
14 15
//!   frame has to keep drawing while that turn streams.
15 16
//!

@@ -17,12 +18,28 @@

17 18
//! which cannot borrow the transcript. It sends each chunk down a channel
18 19
//! instead, and the loop appends it on arrival — so the reply appears as it is
19 20
//! written rather than in one block at the end.
21
//!
22
//! ## The three panes
23
//!
24
//! The middle of the frame shows the transcript, the diff inspector, or a
25
//! program running under a pseudoterminal. Only one of them is up at a time
26
//! and only the one that is up takes keys, which is why [`CoderApp::on_key`]
27
//! dispatches on the pane before it looks at the key.
28
29
use std::path::PathBuf;
30
use std::sync::Arc;
20 31
21 32
use crate::cli::CoderArgs;
33
use crate::composer::complete::{complete, Completion};
34
use crate::composer::history::History;
22 35
use crate::composer::{Composer, ComposerAction};
23
use crate::runtime::{CoderRuntimeSession, Lane};
36
use crate::diff::{DiffMode, FileDiff};
37
use crate::pty::{PtyControl, PtyEvent, PtyScreen, PtySession, DETACH};
38
use crate::runtime::{CoderRuntimeSession, Lane, TurnUsage};
24 39
use crate::tools::{DelegationGate, HarnessToolRegistry};
25
use crate::tui::{composer_text_width, BoxFrame, ChromeView, Entry, Role};
40
use crate::tui::{
41
    composer_text_width, pty_viewport, BoxFrame, ChromeView, DiffPane, Entry, Middle, PtyPane, Role,
42
};
26 43
27 44
use crossterm::{
28 45
    event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},

@@ -31,16 +48,50 @@ use crossterm::{

31 48
};
32 49
use futures::{Stream, StreamExt};
33 50
use ratatui::backend::{Backend, CrosstermBackend};
51
use ratatui::layout::Rect;
52
use ratatui::text::Line;
34 53
use ratatui::Terminal;
35 54
use std::io::{stdout, IsTerminal};
36 55
use std::time::Duration;
37 56
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
38 57
58
/// The commands the composer takes, and what each one does.
59
///
60
/// One list, read by three things: the `/help` output, Tab completion, and the
61
/// dispatch in [`CoderApp::run_command`]. A command that is not handled cannot
62
/// be in this list without failing `every_listed_command_is_handled`.
63
pub const COMMANDS: &[(&str, &str)] = &[
64
    ("clear", "clear the transcript"),
65
    (
66
        "diff",
67
        "what changed: /diff, /diff --staged, /diff <path>, /diff <old> <new>",
68
    ),
69
    ("export", "write the transcript to a file: /export <path>"),
70
    ("help", "list these commands"),
71
    (
72
        "run",
73
        "run a program under a terminal in this frame: /run <command>",
74
    ),
75
];
76
77
fn command_names() -> Vec<&'static str> {
78
    COMMANDS.iter().map(|(name, _)| *name).collect()
79
}
80
39 81
/// A message for the runtime task.
40 82
#[derive(Debug, Clone)]
41 83
pub enum Control {
42 84
    /// Run a turn on this prompt.
43 85
    Prompt(String),
86
    /// Collect a diff. The words are `/diff`'s arguments, already split.
87
    Diff(Vec<String>),
88
    /// Start a program under a pseudoterminal of this size.
89
    Run {
90
        command: Vec<String>,
91
        label: String,
92
        cols: u16,
93
        rows: u16,
94
    },
44 95
}
45 96
46 97
/// A message from the runtime task.

@@ -53,39 +104,113 @@ pub enum TurnEvent {

53 104
    Done(String),
54 105
    /// The turn failed. The session stays open.
55 106
    Failed(String),
56
    /// The model the server's grant named for that turn.
107
    /// The model that answered the last turn.
57 108
    ///
58 109
    /// Reported rather than assumed. `POST /api/v1/threads` does take a
59 110
    /// `model`, but what answers is whatever the returned grant pins — a value
60 111
    /// outside the enum is refused, and a listed model whose provider is not
61 112
    /// configured is refused as `model_unavailable`. So the request is a
62 113
    /// preference and the grant is the fact, and this carries the fact.
114
    ///
115
    /// Read from `CoderRuntimeSession::last_model` rather than from the grant,
116
    /// because the local lane has no grant: it resolves its model with Ollama
117
    /// and `last_grant` stays `None` there for good reasons of its own.
63 118
    Model(String),
119
    /// What the last turn spent, as the server reported it.
120
    Usage(TurnUsage),
121
    /// A diff to inspect.
122
    Diff(Vec<FileDiff>),
123
    /// Something worth putting on the transcript that was not a turn.
124
    Notice(String),
125
    /// A program started, and this is how to talk to it.
126
    PtyOpen {
127
        label: String,
128
        control: Arc<dyn PtyControl>,
129
    },
130
    /// Bytes the program wrote.
131
    PtyOutput(Vec<u8>),
132
    /// The program ended.
133
    PtyExit(u32),
64 134
}
65 135
66 136
/// How often the streaming bullet flips.
67 137
const PULSE: Duration = Duration::from_millis(400);
68 138
139
/// The diff inspector's state.
140
struct DiffView {
141
    files: Vec<FileDiff>,
142
    index: usize,
143
    mode: DiffMode,
144
    scroll: usize,
145
    /// The rows as last rendered, and the width and mode they were rendered
146
    /// for. Kept so scrolling does not re-diff, and rebuilt when any of the
147
    /// three change.
148
    rows: Vec<Line<'static>>,
149
    rendered_for: (usize, usize, DiffMode),
150
}
151
152
impl DiffView {
153
    fn new(files: Vec<FileDiff>) -> Self {
154
        Self {
155
            files,
156
            index: 0,
157
            mode: DiffMode::Unified,
158
            scroll: 0,
159
            rows: Vec::new(),
160
            rendered_for: (usize::MAX, usize::MAX, DiffMode::Unified),
161
        }
162
    }
163
164
    fn rows(&mut self, width: usize) -> &[Line<'static>] {
165
        let key = (self.index, width, self.mode);
166
        if key != self.rendered_for {
167
            self.rows = crate::diff::render(&self.files[self.index], self.mode, width);
168
            self.rendered_for = key;
169
        }
170
        &self.rows
171
    }
172
}
173
174
/// A program running under a pseudoterminal, inside the frame.
175
struct PtyView {
176
    label: String,
177
    screen: PtyScreen,
178
    control: Arc<dyn PtyControl>,
179
    exit: Option<u32>,
180
}
181
69 182
pub struct CoderApp {
70 183
    title: String,
71 184
    entries: Vec<Entry>,
72 185
    composer: Composer,
73
    /// The model the last grant named. Unknown until a turn has opened one.
186
    history: History,
187
    /// What Tab last found, when it found more than one candidate.
188
    completions: Vec<String>,
189
    /// The directory paths are completed in and commands are run in.
190
    cwd: PathBuf,
191
    /// The lane this session was started on, as the status bar names it.
192
    lane: String,
193
    /// The model the last turn answered from. Unknown until a turn has run.
74 194
    model: Option<String>,
195
    usage: TurnUsage,
75 196
    busy: bool,
76 197
    pulse: bool,
77 198
    scrollback: usize,
78 199
    should_exit: bool,
200
    /// The size of the last frame drawn, which is what a child is told.
201
    size: Rect,
202
    diff: Option<DiffView>,
203
    pty: Option<PtyView>,
79 204
}
80 205
81 206
impl CoderApp {
82
    pub fn new(title: &str) -> Self {
207
    pub fn new(title: &str, lane: &Lane) -> Self {
83 208
        let entries = vec![Entry {
84 209
            role: Role::Notice,
85 210
            // Every claim here is one this screen keeps. The old welcome text
86 211
            // invited the reader to type into a session that discarded keys.
87 212
            text: "Type a prompt and press Enter. The reply streams in below \
88
                   as the model writes it."
213
                   as the model writes it. `/help` lists the commands."
89 214
                .to_string(),
90 215
            settled: true,
91 216
        }];

@@ -93,19 +218,46 @@ impl CoderApp {

93 218
            title: title.to_string(),
94 219
            entries,
95 220
            composer: Composer::new(),
221
            history: History::new(),
222
            completions: Vec::new(),
223
            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
224
            lane: lane_label(lane),
96 225
            model: None,
226
            usage: TurnUsage::default(),
97 227
            busy: false,
98 228
            pulse: true,
99 229
            scrollback: 0,
100 230
            should_exit: false,
231
            size: Rect::new(0, 0, 80, 24),
232
            diff: None,
233
            pty: None,
101 234
        }
102 235
    }
103 236
104
    /// The model the last turn's grant named, if a turn has opened one.
237
    /// Keep this session's prompts in `path` and start with what is in it.
238
    ///
239
    /// Separate from [`CoderApp::new`] so that a test drives a session whose
240
    /// history is in memory and writes nothing to the reader's home.
241
    pub fn with_history_file(mut self, path: PathBuf) -> Self {
242
        self.history = History::load(path);
243
        self
244
    }
245
246
    /// Complete paths in `cwd` and run commands there.
247
    pub fn with_working_directory(mut self, cwd: PathBuf) -> Self {
248
        self.cwd = cwd;
249
        self
250
    }
251
252
    /// The model the last turn answered from, if a turn has run.
105 253
    pub fn model(&self) -> Option<&str> {
106 254
        self.model.as_deref()
107 255
    }
108 256
257
    pub fn usage(&self) -> TurnUsage {
258
        self.usage
259
    }
260
109 261
    pub fn busy(&self) -> bool {
110 262
        self.busy
111 263
    }

@@ -118,7 +270,30 @@ impl CoderApp {

118 270
        &self.entries
119 271
    }
120 272
121
    /// The transcript as text, for `--export`.
273
    /// Whether a program is running under a pseudoterminal in this frame.
274
    pub fn running(&self) -> bool {
275
        self.pty.as_ref().is_some_and(|pty| pty.exit.is_none())
276
    }
277
278
    /// The exit code of the program in the pane, once it has one.
279
    ///
280
    /// For a caller waiting on a program rather than asserting about one: what
281
    /// the reader sees is the frame, and the frame is what the tests assert.
282
    pub fn pty_exit(&self) -> Option<u32> {
283
        self.pty.as_ref().and_then(|pty| pty.exit)
284
    }
285
286
    /// What the program has drawn so far, as text.
287
    pub fn pty_text(&self) -> Option<String> {
288
        self.pty.as_ref().map(|pty| pty.screen.text())
289
    }
290
291
    /// Whether the diff inspector is up.
292
    pub fn inspecting(&self) -> bool {
293
        self.diff.is_some()
294
    }
295
296
    /// The transcript as text, for `--export` and `/export`.
122 297
    pub fn transcript(&self) -> String {
123 298
        self.entries
124 299
            .iter()

@@ -149,6 +324,14 @@ impl CoderApp {

149 324
        if prompt.is_empty() {
150 325
            return;
151 326
        }
327
        self.history.record(&prompt);
328
        self.completions.clear();
329
330
        if prompt.starts_with('/') {
331
            self.run_command(&prompt, control);
332
            return;
333
        }
334
152 335
        self.push(Role::You, prompt.clone());
153 336
        self.entries.push(Entry::streaming(Role::Assistant));
154 337
        self.busy = true;

@@ -162,6 +345,83 @@ impl CoderApp {

162 345
        }
163 346
    }
164 347
348
    /// Run one of the session's own commands.
349
    ///
350
    /// Anything starting with `/` comes here. A name this does not know is
351
    /// refused rather than sent to the model, because a mistyped `/diff` that
352
    /// silently became a prompt is a worse answer than being told.
353
    fn run_command(&mut self, line: &str, control: &UnboundedSender<Control>) {
354
        let words = crate::pty::split_command(line.trim_start_matches('/'));
355
        let Some(name) = words.first().cloned() else {
356
            self.push(Role::Error, "A command needs a name. Try `/help`.");
357
            return;
358
        };
359
        let arguments = &words[1..];
360
        self.push(Role::You, line.to_string());
361
362
        match name.as_str() {
363
            "help" => {
364
                let listed = COMMANDS
365
                    .iter()
366
                    .map(|(name, what)| format!("/{name} — {what}"))
367
                    .collect::<Vec<_>>()
368
                    .join("\n");
369
                self.push(Role::Notice, listed);
370
            }
371
            "clear" => {
372
                self.entries.clear();
373
                self.scrollback = 0;
374
            }
375
            "export" => match arguments.first() {
376
                None => self.push(Role::Error, "`/export` needs a path: `/export notes.txt`."),
377
                Some(path) => match std::fs::write(path, self.transcript()) {
378
                    Ok(()) => self.push(Role::Notice, format!("Transcript written to {path}.")),
379
                    Err(error) => {
380
                        self.push(Role::Error, format!("Could not write {path}: {error}"))
381
                    }
382
                },
383
            },
384
            "diff" => {
385
                if control.send(Control::Diff(arguments.to_vec())).is_err() {
386
                    self.push(Role::Error, "The runtime task is gone.");
387
                }
388
            }
389
            "run" => {
390
                // The words after `/run` are the command, but a line a shell
391
                // would change the meaning of is given to a shell instead, so
392
                // `/run ls | wc -l` runs what it looks like it runs.
393
                let rest = line
394
                    .trim_start_matches('/')
395
                    .strip_prefix("run")
396
                    .unwrap_or("")
397
                    .trim();
398
                if rest.is_empty() {
399
                    self.push(Role::Error, "`/run` needs a command: `/run git status`.");
400
                    return;
401
                }
402
                let command = if crate::pty::needs_a_shell(rest) {
403
                    crate::pty::shell_command(rest)
404
                } else {
405
                    crate::pty::split_command(rest)
406
                };
407
                let (cols, rows) = pty_viewport(self.size);
408
                let message = Control::Run {
409
                    command,
410
                    label: rest.to_string(),
411
                    cols,
412
                    rows,
413
                };
414
                if control.send(message).is_err() {
415
                    self.push(Role::Error, "The runtime task is gone.");
416
                }
417
            }
418
            other => self.push(
419
                Role::Error,
420
                format!("There is no `/{other}`. `/help` lists the commands."),
421
            ),
422
        }
423
    }
424
165 425
    /// Settle whatever was streaming and take the composer off hold.
166 426
    fn finish_turn(&mut self) {
167 427
        if let Some(last) = self.entries.last_mut() {

@@ -203,6 +463,34 @@ impl CoderApp {

203 463
                self.push(Role::Error, format!("Turn failed: {message}"));
204 464
            }
205 465
            TurnEvent::Model(model) => self.model = Some(model),
466
            TurnEvent::Usage(usage) => self.usage = usage,
467
            TurnEvent::Notice(message) => self.push(Role::Notice, message),
468
            TurnEvent::Diff(files) => {
469
                if files.is_empty() {
470
                    self.push(Role::Notice, "Nothing has changed.");
471
                } else {
472
                    self.diff = Some(DiffView::new(files));
473
                }
474
            }
475
            TurnEvent::PtyOpen { label, control } => {
476
                let (cols, rows) = pty_viewport(self.size);
477
                self.pty = Some(PtyView {
478
                    label,
479
                    screen: PtyScreen::new(cols, rows),
480
                    control,
481
                    exit: None,
482
                });
483
            }
484
            TurnEvent::PtyOutput(bytes) => {
485
                if let Some(pty) = self.pty.as_mut() {
486
                    pty.screen.feed(&bytes);
487
                }
488
            }
489
            TurnEvent::PtyExit(code) => {
490
                if let Some(pty) = self.pty.as_mut() {
491
                    pty.exit = Some(code);
492
                }
493
            }
206 494
        }
207 495
    }
208 496

@@ -214,12 +502,130 @@ impl CoderApp {

214 502
        }
215 503
    }
216 504
505
    /// Note the size of the frame, and tell a running program about it.
506
    ///
507
    /// This is where a terminal resize becomes a `SIGWINCH` for the child: the
508
    /// emulated screen is resized, and only if that changed anything is the
509
    /// pseudoterminal's own window size set, which is the call the kernel
510
    /// turns into the signal.
511
    pub fn on_size(&mut self, area: Rect) {
512
        self.size = area;
513
        let (cols, rows) = pty_viewport(area);
514
        if let Some(pty) = self.pty.as_mut() {
515
            if pty.screen.resize(cols, rows) {
516
                pty.control.resize(cols, rows);
517
            }
518
        }
519
    }
520
217 521
    pub fn on_key(&mut self, key: &KeyEvent, width: u16, control: &UnboundedSender<Control>) {
218 522
        // A key release reported by an enhanced protocol is not a keystroke.
219 523
        if key.kind == KeyEventKind::Release {
220 524
            return;
221 525
        }
526
        // Whichever pane is up takes the keyboard first. A program under a
527
        // pseudoterminal takes all of it — including Esc and Ctrl+C, which a
528
        // full-screen program needs — so the only key held back is the one
529
        // that takes the keyboard away again.
530
        if self.pty.is_some() {
531
            self.on_pty_key(key);
532
            return;
533
        }
534
        if self.diff.is_some() {
535
            self.on_diff_key(key, width);
536
            return;
537
        }
538
        self.on_transcript_key(key, width, control);
539
    }
540
541
    fn on_pty_key(&mut self, key: &KeyEvent) {
542
        let detach = key.code == DETACH.code && key.modifiers == DETACH.modifiers;
543
        let exit = self.pty.as_ref().and_then(|pty| pty.exit);
222 544
545
        // A program that has ended leaves its screen up, because that screen
546
        // is usually the answer. Dismissing it is what these keys do.
547
        if let Some(code) = exit {
548
            if detach || matches!(key.code, KeyCode::Enter | KeyCode::Esc) {
549
                let label = self.pty.take().map(|pty| pty.label).unwrap_or_default();
550
                self.push(
551
                    Role::Notice,
552
                    if code == 0 {
553
                        format!("`{label}` finished.")
554
                    } else {
555
                        format!("`{label}` exited with code {code}.")
556
                    },
557
                );
558
            }
559
            return;
560
        }
561
562
        if detach {
563
            if let Some(pty) = self.pty.take() {
564
                pty.control.kill();
565
                self.push(Role::Notice, format!("Stopped `{}`.", pty.label));
566
            }
567
            return;
568
        }
569
570
        // Everything else is the program's. It is encoded as the bytes a
571
        // terminal would have sent, in the cursor mode the program asked for.
572
        if let Some(pty) = self.pty.as_ref() {
573
            if let Some(bytes) = crate::pty::encode_key(key, pty.screen.application_cursor()) {
574
                pty.control.write(&bytes);
575
            }
576
        }
577
    }
578
579
    fn on_diff_key(&mut self, key: &KeyEvent, width: u16) {
580
        match key.code {
581
            KeyCode::Esc | KeyCode::Char('q') => {
582
                self.diff = None;
583
                return;
584
            }
585
            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
586
                self.should_exit = true;
587
                return;
588
            }
589
            _ => {}
590
        }
591
592
        let page = usize::from(self.size.height.saturating_sub(10)).max(1);
593
        let Some(diff) = self.diff.as_mut() else {
594
            return;
595
        };
596
        match key.code {
597
            KeyCode::Char('v') => {
598
                diff.mode = diff.mode.toggled();
599
                diff.scroll = 0;
600
            }
601
            KeyCode::Tab | KeyCode::BackTab => {
602
                let total = diff.files.len();
603
                diff.index = if key.code == KeyCode::Tab {
604
                    (diff.index + 1) % total
605
                } else {
606
                    (diff.index + total - 1) % total
607
                };
608
                diff.scroll = 0;
609
            }
610
            KeyCode::Down => diff.scroll = diff.scroll.saturating_add(1),
611
            KeyCode::Up => diff.scroll = diff.scroll.saturating_sub(1),
612
            KeyCode::PageDown => diff.scroll = diff.scroll.saturating_add(page),
613
            KeyCode::PageUp => diff.scroll = diff.scroll.saturating_sub(page),
614
            KeyCode::Home => diff.scroll = 0,
615
            _ => {}
616
        }
617
        // Scrolling past the last row would leave an empty pane with no way of
618
        // telling that it is the end rather than a failure to draw.
619
        let last = diff.rows(diff_body_width(width)).len().saturating_sub(1);
620
        diff.scroll = diff.scroll.min(last);
621
    }
622
623
    fn on_transcript_key(
624
        &mut self,
625
        key: &KeyEvent,
626
        width: u16,
627
        control: &UnboundedSender<Control>,
628
    ) {
223 629
        match key.code {
224 630
            KeyCode::Esc => {
225 631
                self.should_exit = true;

@@ -246,42 +652,131 @@ impl CoderApp {

246 652
            return;
247 653
        }
248 654
655
        if key.code == KeyCode::Tab && key.modifiers.is_empty() {
656
            self.on_tab();
657
            return;
658
        }
659
249 660
        match self.composer.handle_key(key, composer_text_width(width)) {
250 661
            ComposerAction::Submit(text) => self.submit(text, control),
251
            ComposerAction::Redraw => self.scrollback = 0,
662
            ComposerAction::Redraw => {
663
                // Editing ends a history walk: the next Up starts again from
664
                // what is now in the composer rather than from where the walk
665
                // had got to.
666
                self.history.stop_walking();
667
                self.completions.clear();
668
                self.scrollback = 0;
669
            }
670
            ComposerAction::Moved => self.completions.clear(),
252 671
            ComposerAction::Ignored => match key.code {
253
                // Up and Down reach the transcript once the caret has run out
254
                // of composer to move through.
255
                KeyCode::Up => self.scrollback = self.scrollback.saturating_add(1),
256
                KeyCode::Down => self.scrollback = self.scrollback.saturating_sub(1),
672
                // Up and Down reach the input history once the caret has run
673
                // out of composer to move through. Scrolling the transcript is
674
                // PgUp and PgDn, which the status bar names.
675
                KeyCode::Up => {
676
                    if let Some(prompt) = self.history.previous(self.composer.text()) {
677
                        self.composer.set_text(&prompt);
678
                        self.completions.clear();
679
                    }
680
                }
681
                KeyCode::Down => {
682
                    if let Some(prompt) = self.history.forward() {
683
                        self.composer.set_text(&prompt);
684
                        self.completions.clear();
685
                    }
686
                }
257 687
                _ => {}
258 688
            },
259 689
        }
260 690
    }
261 691
692
    /// Tab: complete the word at the caret.
693
    fn on_tab(&mut self) {
694
        let Completion { insert, candidates } = complete(
695
            self.composer.text(),
696
            self.composer.cursor_byte(),
697
            &command_names(),
698
            &self.cwd,
699
        );
700
        if !insert.is_empty() {
701
            self.composer.insert_str(&insert);
702
            self.history.stop_walking();
703
        }
704
        self.completions = candidates;
705
    }
706
262 707
    /// Draw one frame.
263
    pub fn draw<B: Backend>(&self, terminal: &mut Terminal<B>) -> std::io::Result<()> {
708
    pub fn draw<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> std::io::Result<()> {
709
        let area = terminal
710
            .size()
711
            .map(|size| Rect::new(0, 0, size.width, size.height))?;
712
        self.on_size(area);
713
714
        // The diff rows are built before the frame because the renderer takes
715
        // them borrowed, and building them needs the view mutably.
716
        let body = diff_body_width(area.width);
717
        let diff_rows: Vec<Line<'static>> = match self.diff.as_mut() {
718
            Some(diff) => diff.rows(body).to_vec(),
719
            None => Vec::new(),
720
        };
721
722
        let text_width = composer_text_width(area.width);
723
        let rows = self.composer.rows(text_width);
724
        let cursor = self.composer.cursor_rowcol(text_width);
725
        let completions = self.completions.clone();
726
727
        let middle = match (&self.diff, &self.pty) {
728
            (_, Some(pty)) => Middle::Pty(PtyPane {
729
                command: &pty.label,
730
                screen: &pty.screen,
731
                exit: pty.exit,
732
            }),
733
            (Some(diff), None) => Middle::Diff(DiffPane {
734
                path: &diff.files[diff.index].path,
735
                position: (diff.index, diff.files.len()),
736
                mode: diff.mode,
737
                rows: &diff_rows,
738
                scroll: diff.scroll,
739
            }),
740
            (None, None) => Middle::Transcript,
741
        };
742
264 743
        let frame = BoxFrame::new(&self.title);
265
        terminal.draw(|f| {
266
            let area = f.area();
267
            let rows = self.composer.rows(composer_text_width(area.width));
268
            let cursor = self.composer.cursor_rowcol(composer_text_width(area.width));
269
            let view = ChromeView {
270
                title: &self.title,
271
                entries: &self.entries,
272
                composer_rows: &rows,
273
                composer_cursor: cursor,
274
                model: self.model.as_deref(),
275
                busy: self.busy,
276
                pulse: self.pulse,
277
                scrollback: self.scrollback,
278
            };
279
            frame.render(f, area, &view);
280
        })?;
744
        let view = ChromeView {
745
            title: &self.title,
746
            entries: &self.entries,
747
            middle,
748
            composer_rows: &rows,
749
            composer_cursor: cursor,
750
            completions: &completions,
751
            model: self.model.as_deref(),
752
            lane: &self.lane,
753
            usage: self.usage,
754
            busy: self.busy,
755
            pulse: self.pulse,
756
            scrollback: self.scrollback,
757
        };
758
        terminal.draw(|f| frame.render(f, f.area(), &view))?;
281 759
        Ok(())
282 760
    }
283 761
}
284 762
763
/// The width the diff inspector's rows are built for, inside a frame that wide.
764
fn diff_body_width(frame_width: u16) -> usize {
765
    usize::from(frame_width).saturating_sub(2).max(8)
766
}
767
768
/// The lane, as the status bar names it: what it is called, and its tier when
769
/// it belongs to one.
770
///
771
/// A model named directly belongs to no tier, which is a different answer from
772
/// `auto` and is worth not inventing one for.
773
fn lane_label(lane: &Lane) -> String {
774
    match lane.tier() {
775
        Some(tier) => format!("{} ({tier})", lane.label()),
776
        None => lane.label(),
777
    }
778
}
779
285 780
/// Drive the session until the reader exits or the terminal event stream ends.
286 781
///
287 782
/// `keepalive` is a sender for the turn channel that this function holds for

@@ -314,6 +809,9 @@ where

314 809
        tokio::select! {
315 810
            event = events.next() => match event {
316 811
                Some(Ok(Event::Key(key))) => app.on_key(&key, width, &control),
812
                // A resize is drawn on the next pass, and `draw` is what tells
813
                // a running child its new size.
814
                Some(Ok(Event::Resize(_, _))) => {}
317 815
                Some(Ok(_)) => {}
318 816
                // A terminal that has gone away is an exit, not an error to
319 817
                // report into a screen nobody can see.

@@ -329,12 +827,13 @@ where

329 827
    }
330 828
}
331 829
332
/// Own the session and run the turns it is asked for.
830
/// Own the session and do what the app asks for.
333 831
pub async fn runtime_actor(
334 832
    mut session: CoderRuntimeSession,
335 833
    mut control: UnboundedReceiver<Control>,
336 834
    events: UnboundedSender<TurnEvent>,
337 835
) {
836
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
338 837
    while let Some(message) = control.recv().await {
339 838
        match message {
340 839
            Control::Prompt(prompt) => {

@@ -344,11 +843,14 @@ pub async fn runtime_actor(

344 843
                        let _ = sink.send(TurnEvent::Chunk(chunk.to_string()));
345 844
                    })
346 845
                    .await;
347
                // Report the grant's model before the turn settles, so the
348
                // status bar names what answered rather than what was asked.
349
                if let Some(grant) = &session.last_grant {
350
                    let _ = events.send(TurnEvent::Model(grant.model.clone()));
846
                // Report what answered and what it cost before the turn
847
                // settles, so the status bar is right by the time the composer
848
                // comes off hold. `last_model` and not the grant: the local
849
                // lane resolves its model with Ollama and holds no grant.
850
                if let Some(model) = &session.last_model {
851
                    let _ = events.send(TurnEvent::Model(model.clone()));
351 852
                }
853
                let _ = events.send(TurnEvent::Usage(session.last_usage));
352 854
                let event = match result {
353 855
                    Ok(answer) => TurnEvent::Done(answer),
354 856
                    Err(error) => TurnEvent::Failed(error.to_string()),

@@ -357,10 +859,120 @@ pub async fn runtime_actor(

357 859
                    return;
358 860
                }
359 861
            }
862
            Control::Diff(arguments) => {
863
                let event = match collect_diff(&arguments, &cwd).await {
864
                    Ok(files) => TurnEvent::Diff(files),
865
                    Err(why) => TurnEvent::Notice(why),
866
                };
867
                if events.send(event).is_err() {
868
                    return;
869
                }
870
            }
871
            Control::Run {
872
                command,
873
                label,
874
                cols,
875
                rows,
876
            } => match PtySession::spawn(&command, Some(cwd.clone()), cols, rows) {
877
                Err(error) => {
878
                    let _ = events.send(TurnEvent::Notice(format!("Could not run it: {error}")));
879
                }
880
                Ok((session, mut output)) => {
881
                    let opened = events.send(TurnEvent::PtyOpen {
882
                        label,
883
                        control: session,
884
                    });
885
                    if opened.is_err() {
886
                        return;
887
                    }
888
                    // Forwarded from a task of its own so this actor stays
889
                    // able to answer while the program runs.
890
                    let sink = events.clone();
891
                    tokio::spawn(async move {
892
                        while let Some(event) = output.recv().await {
893
                            let message = match event {
894
                                PtyEvent::Output(bytes) => TurnEvent::PtyOutput(bytes),
895
                                PtyEvent::Exit(code) => TurnEvent::PtyExit(code),
896
                            };
897
                            if sink.send(message).is_err() {
898
                                return;
899
                            }
900
                        }
901
                    });
902
                }
903
            },
360 904
        }
361 905
    }
362 906
}
363 907
908
/// Collect the diff `/diff` asked for.
909
///
910
/// With two paths that both exist, the two files are compared directly. With
911
/// anything else, git is asked — because for a working tree git already knows
912
/// what the index and `HEAD` hold, and recomputing that from files on disk
913
/// would answer a different question.
914
pub async fn collect_diff(
915
    arguments: &[String],
916
    cwd: &std::path::Path,
917
) -> Result<Vec<FileDiff>, String> {
918
    if arguments.len() == 2 {
919
        let (old, new) = (cwd.join(&arguments[0]), cwd.join(&arguments[1]));
920
        if old.is_file() && new.is_file() {
921
            let read = |path: &std::path::Path| {
922
                std::fs::read_to_string(path)
923
                    .map_err(|error| format!("Could not read {}: {error}", path.display()))
924
            };
925
            let (before, after) = (read(&old)?, read(&new)?);
926
            return Ok(vec![FileDiff {
927
                path: arguments[1].clone(),
928
                renamed_from: Some(arguments[0].clone()),
929
                hunks: crate::diff::compare(&before, &after, crate::diff::CONTEXT),
930
                note: None,
931
            }]);
932
        }
933
    }
934
935
    let mut git: Vec<String> = vec!["--no-pager".into(), "diff".into()];
936
    let staged = arguments
937
        .iter()
938
        .any(|word| word == "--staged" || word == "--cached");
939
    if staged {
940
        git.push("--cached".into());
941
    } else {
942
        // Against `HEAD` rather than the index, so `/diff` answers "what have
943
        // I changed since the last commit" whether or not anything is staged.
944
        git.push("HEAD".into());
945
    }
946
    let paths: Vec<&String> = arguments
947
        .iter()
948
        .filter(|word| !word.starts_with("--"))
949
        .collect();
950
    if !paths.is_empty() {
951
        git.push("--".into());
952
        git.extend(paths.into_iter().cloned());
953
    }
954
955
    let output = tokio::process::Command::new("git")
956
        .args(&git)
957
        .current_dir(cwd)
958
        .output()
959
        .await
960
        .map_err(|error| format!("Could not run git: {error}"))?;
961
962
    if !output.status.success() {
963
        let message = String::from_utf8_lossy(&output.stderr);
964
        let message = message.trim();
965
        return Err(if message.is_empty() {
966
            "git could not produce a diff here.".to_string()
967
        } else {
968
            format!("git said: {message}")
969
        });
970
    }
971
    Ok(crate::diff::parse_unified(&String::from_utf8_lossy(
972
        &output.stdout,
973
    )))
974
}
975
364 976
/// Put the terminal back however this function is left, including by a panic
365 977
/// or by an error on the way out of a turn.
366 978
struct TerminalGuard;

@@ -453,7 +1065,8 @@ pub async fn run_tui(

453 1065
    let (event_tx, mut event_rx) = unbounded_channel::<TurnEvent>();
454 1066
    let runtime = tokio::spawn(runtime_actor(session, control_rx, event_tx.clone()));
455 1067
456
    let mut app = CoderApp::new("openagents coder");
1068
    let mut app =
1069
        CoderApp::new("openagents coder", &lane).with_history_file(History::default_path());
457 1070
    if let Some(prompt) = args.prompt.clone() {
458 1071
        app.submit(prompt, &control_tx);
459 1072
    }

@@ -607,4 +1220,38 @@ mod tests {

607 1220
        assert_eq!(gate.user_token.as_deref(), Some("secret-token"));
608 1221
        assert_eq!(gate.max_count, crate::delegate::MAX_DELEGATE_COUNT);
609 1222
    }
1223
1224
    /// The status bar names the lane and, when the lane has one, its tier.
1225
    #[test]
1226
    fn the_lane_label_carries_the_tier_only_when_there_is_one() {
1227
        assert_eq!(lane_label(&Lane::Flash), "Coder Flash (flash)");
1228
        assert_eq!(lane_label(&Lane::Auto), "Coder Auto (auto)");
1229
        assert_eq!(
1230
            lane_label(&Lane::Local(String::new())),
1231
            "Coder Local (local)"
1232
        );
1233
        // A model named directly belongs to no tier, so none is invented.
1234
        assert_eq!(lane_label(&Lane::OxAlpha), "Coder (ox-alpha)");
1235
        assert_eq!(
1236
            lane_label(&Lane::Named("some-model".to_string())),
1237
            "Coder (some-model)"
1238
        );
1239
    }
1240
1241
    /// Every command the composer offers has to be one the dispatch handles.
1242
    /// `/help` lists this table and Tab completes from it, so an entry with no
1243
    /// arm behind it would be advertised and inert.
1244
    #[test]
1245
    fn every_listed_command_is_handled() {
1246
        let (tx, _rx) = unbounded_channel();
1247
        for (name, _) in COMMANDS {
1248
            let mut app = CoderApp::new("t", &Lane::OxAlpha);
1249
            app.run_command(&format!("/{name}"), &tx);
1250
            let said = app.transcript();
1251
            assert!(
1252
                !said.contains(&format!("There is no `/{name}`")),
1253
                "`/{name}` is listed and not handled"
1254
            );
1255
        }
1256
    }
610 1257
}
crates/openagents-cli/src/lib.rs modified +3

@@ -20,13 +20,16 @@ pub mod composer;

20 20
pub mod computer;
21 21
pub mod delegate;
22 22
pub mod diag;
23
pub mod diff;
23 24
pub mod fleet;
24 25
pub mod forum;
25 26
pub mod identity;
26 27
pub mod interactive;
28
pub mod markdown;
27 29
pub mod memory_client;
28 30
pub mod plugins;
29 31
pub mod provider;
32
pub mod pty;
30 33
pub mod repo;
31 34
pub mod resume;
32 35
pub mod runtime;
crates/openagents-cli/src/markdown.rs added +1120

@@ -0,0 +1,1120 @@

1
//! Markdown and code highlighting for the coder transcript.
2
//!
3
//! A model writes markdown. Until now the transcript put it on the screen
4
//! verbatim, so `**important**` arrived with its asterisks and a fenced block
5
//! of Rust looked like prose.
6
//!
7
//! Two properties shape this module.
8
//!
9
//! **It renders what has arrived so far.** A reply is drawn on every chunk, so
10
//! this is called on text whose last line is usually half-written. Nothing here
11
//! waits for a closing delimiter before it will draw: an unclosed `**` renders
12
//! as the two characters that are actually there, and re-renders as bold on the
13
//! chunk that closes it. A fence with no closing fence keeps highlighting the
14
//! rows inside it rather than holding them back. That is why the parse is
15
//! per-line and single-pass — a parser that needed the whole document could not
16
//! draw a document that is still being written.
17
//!
18
//! **It is pure.** `render` takes text and a width and returns rows. It reads
19
//! no terminal and no clock, so a test asserts on the rows a reader would see.
20
//!
21
//! The highlighter is a lexer, not a parser: it knows each language's comment
22
//! syntax, string delimiters, keyword set, and number grammar, and colours
23
//! those. It does not know types from values or resolve anything. That is the
24
//! honest ceiling of a few hundred lines, and it is most of what highlighting
25
//! buys you in a transcript.
26
27
use ratatui::style::{Color, Modifier, Style};
28
use ratatui::text::{Line, Span};
29
use unicode_segmentation::UnicodeSegmentation as _;
30
use unicode_width::UnicodeWidthStr as _;
31
32
/// The rail down the left of a fenced code block, and its two end caps.
33
const CODE_RAIL: &str = "│ ";
34
const CODE_OPEN: &str = "╭─";
35
const CODE_CLOSE: &str = "╰─";
36
/// The marker in front of a bullet list item.
37
const BULLET: &str = "• ";
38
/// The rail down the left of a block quote.
39
const QUOTE_RAIL: &str = "▎ ";
40
41
fn dim() -> Style {
42
    Style::default().fg(Color::DarkGray)
43
}
44
45
fn heading_style() -> Style {
46
    Style::default()
47
        .fg(Color::Cyan)
48
        .add_modifier(Modifier::BOLD)
49
}
50
51
/// Render markdown into rows no wider than `width` columns.
52
pub fn render(text: &str, width: usize) -> Vec<Line<'static>> {
53
    let width = width.max(4);
54
    let mut rows: Vec<Line<'static>> = Vec::new();
55
    let mut fence: Option<Fence> = None;
56
57
    for raw in text.split('\n') {
58
        match fence.as_mut() {
59
            Some(open) => {
60
                if closes(raw, open) {
61
                    rows.push(Line::from(Span::styled(CODE_CLOSE.to_string(), dim())));
62
                    fence = None;
63
                } else {
64
                    rows.push(code_row(raw, open, width));
65
                }
66
            }
67
            None => match opens(raw) {
68
                Some(open) => {
69
                    let label = if open.language.is_empty() {
70
                        CODE_OPEN.to_string()
71
                    } else {
72
                        format!("{CODE_OPEN} {}", open.language)
73
                    };
74
                    rows.push(Line::from(Span::styled(label, dim())));
75
                    fence = Some(open);
76
                }
77
                None => rows.extend(block_row(raw, width)),
78
            },
79
        }
80
    }
81
    rows
82
}
83
84
// ------------------------------------------------------------------ blocks
85
86
/// One non-code line, as however many wrapped rows it needs.
87
fn block_row(raw: &str, width: usize) -> Vec<Line<'static>> {
88
    let indent = raw.len() - raw.trim_start().len();
89
    let body = raw.trim_start();
90
91
    if body.is_empty() {
92
        return vec![Line::from("")];
93
    }
94
95
    if is_rule(body) {
96
        return vec![Line::from(Span::styled("─".repeat(width), dim()))];
97
    }
98
99
    if let Some(rest) = heading(body) {
100
        let mut spans = vec![Span::styled(rest.to_string(), heading_style())];
101
        // A heading is short enough to wrap as one styled run.
102
        if rest.width() > width {
103
            spans = inline(rest, heading_style());
104
        }
105
        return wrap_spans(spans, width, 0, 0);
106
    }
107
108
    if let Some(rest) = body.strip_prefix("> ").or_else(|| body.strip_prefix(">")) {
109
        let lead = Span::styled(QUOTE_RAIL.to_string(), dim());
110
        let text = inline(rest, dim().add_modifier(Modifier::ITALIC));
111
        let mut spans = vec![lead];
112
        spans.extend(text);
113
        return wrap_spans(spans, width, QUOTE_RAIL.width(), indent);
114
    }
115
116
    if let Some(rest) = bullet(body) {
117
        let mut spans = vec![Span::styled(BULLET.to_string(), dim())];
118
        spans.extend(inline(rest, Style::default()));
119
        return wrap_spans(spans, width, BULLET.width(), indent);
120
    }
121
122
    if let Some((marker, rest)) = ordered(body) {
123
        let hang = marker.width();
124
        let mut spans = vec![Span::styled(marker, dim())];
125
        spans.extend(inline(rest, Style::default()));
126
        return wrap_spans(spans, width, hang, indent);
127
    }
128
129
    wrap_spans(inline(body, Style::default()), width, 0, indent)
130
}
131
132
fn is_rule(body: &str) -> bool {
133
    let trimmed = body.trim_end();
134
    let first = match trimmed.chars().next() {
135
        Some(c @ ('-' | '*' | '_')) => c,
136
        _ => return false,
137
    };
138
    trimmed.len() >= 3 && trimmed.chars().all(|c| c == first)
139
}
140
141
/// The text of an ATX heading, with the `#` run and its space removed.
142
fn heading(body: &str) -> Option<&str> {
143
    let hashes = body.chars().take_while(|c| *c == '#').count();
144
    if hashes == 0 || hashes > 6 {
145
        return None;
146
    }
147
    let rest = &body[hashes..];
148
    // `#tag` is not a heading; `# ` is.
149
    rest.strip_prefix(' ').map(str::trim_end)
150
}
151
152
fn bullet(body: &str) -> Option<&str> {
153
    for marker in ["- ", "* ", "+ "] {
154
        if let Some(rest) = body.strip_prefix(marker) {
155
            return Some(rest);
156
        }
157
    }
158
    None
159
}
160
161
/// `12. ` at the head of a line, returned as the marker and the rest.
162
fn ordered(body: &str) -> Option<(String, &str)> {
163
    let digits = body.chars().take_while(char::is_ascii_digit).count();
164
    if digits == 0 || digits > 9 {
165
        return None;
166
    }
167
    let rest = &body[digits..];
168
    let rest = rest
169
        .strip_prefix(". ")
170
        .or_else(|| rest.strip_prefix(") "))?;
171
    Some((format!("{} ", &body[..digits + 1]), rest))
172
}
173
174
// ------------------------------------------------------------------ fences
175
176
struct Fence {
177
    /// The character the fence was opened with, and how many of it.
178
    marker: char,
179
    length: usize,
180
    language: String,
181
    /// Set while a `/* … */` run is open, so the next row starts as comment.
182
    in_block_comment: bool,
183
}
184
185
/// A fence opener, if this line is one.
186
fn opens(raw: &str) -> Option<Fence> {
187
    let body = raw.trim_start();
188
    let marker = match body.chars().next() {
189
        Some(c @ ('`' | '~')) => c,
190
        _ => return None,
191
    };
192
    let length = body.chars().take_while(|c| *c == marker).count();
193
    if length < 3 {
194
        return None;
195
    }
196
    let language = body[length..].trim().to_lowercase();
197
    // ```rust,ignore and ```rust {n} both name rust.
198
    let language = language
199
        .split(|c: char| c == ',' || c.is_whitespace())
200
        .next()
201
        .unwrap_or("")
202
        .to_string();
203
    Some(Fence {
204
        marker,
205
        length,
206
        language,
207
        in_block_comment: false,
208
    })
209
}
210
211
/// Whether this line closes `open`.
212
fn closes(raw: &str, open: &Fence) -> bool {
213
    let body = raw.trim();
214
    !body.is_empty()
215
        && body.chars().count() >= open.length
216
        && body.chars().all(|c| c == open.marker)
217
}
218
219
/// One row of code: the rail, then the highlighted line, truncated to fit.
220
///
221
/// Code is truncated rather than wrapped. A wrapped line of code reads as two
222
/// statements, and the indentation that carries a block's structure is lost on
223
/// the continuation.
224
fn code_row(raw: &str, fence: &mut Fence, width: usize) -> Line<'static> {
225
    let syntax = Syntax::for_language(&fence.language);
226
    let body = width.saturating_sub(CODE_RAIL.width());
227
    let (spans, still_open) = highlight(raw, &syntax, fence.in_block_comment);
228
    fence.in_block_comment = still_open;
229
    let mut row = vec![Span::styled(CODE_RAIL.to_string(), dim())];
230
    row.extend(truncate_spans(spans, body));
231
    Line::from(row)
232
}
233
234
// ------------------------------------------------------------------ inline
235
236
/// Parse one line of inline markdown into styled spans.
237
///
238
/// `base` is the style text carries when no inline mark applies, so a
239
/// blockquote's body stays dim and italic under its own emphasis.
240
fn inline(text: &str, base: Style) -> Vec<Span<'static>> {
241
    let mut spans: Vec<Span<'static>> = Vec::new();
242
    let mut plain = String::new();
243
    let bytes = text.as_bytes();
244
    let mut i = 0usize;
245
246
    macro_rules! flush {
247
        () => {
248
            if !plain.is_empty() {
249
                spans.push(Span::styled(std::mem::take(&mut plain), base));
250
            }
251
        };
252
    }
253
254
    while i < text.len() {
255
        let rest = &text[i..];
256
257
        // A backslash escapes the next character, which is then literal.
258
        if let Some(escaped) = rest.strip_prefix('\\') {
259
            if let Some(c) = escaped.chars().next() {
260
                if "\\`*_[]()#-+.!>".contains(c) {
261
                    plain.push(c);
262
                    i += 1 + c.len_utf8();
263
                    continue;
264
                }
265
            }
266
        }
267
268
        if bytes[i] == b'`' {
269
            let ticks = rest.chars().take_while(|c| *c == '`').count();
270
            let fence = "`".repeat(ticks);
271
            if let Some(end) = rest[ticks..].find(&fence) {
272
                flush!();
273
                let code = &rest[ticks..ticks + end];
274
                spans.push(Span::styled(
275
                    code.to_string(),
276
                    Style::default().fg(Color::Yellow),
277
                ));
278
                i += ticks + end + ticks;
279
                continue;
280
            }
281
        }
282
283
        let emphasis = [
284
            ("**", Modifier::BOLD),
285
            ("__", Modifier::BOLD),
286
            ("*", Modifier::ITALIC),
287
            ("_", Modifier::ITALIC),
288
        ]
289
        .into_iter()
290
        .find_map(|(mark, modifier)| {
291
            let after = rest.strip_prefix(mark)?;
292
            // An empty run (`**` on its own) is not emphasis, and a run with no
293
            // closer is text that has not finished arriving.
294
            let end = after.find(mark).filter(|end| *end > 0)?;
295
            Some((mark.len(), end, modifier))
296
        });
297
        if let Some((mark, end, modifier)) = emphasis {
298
            flush!();
299
            spans.push(Span::styled(
300
                rest[mark..mark + end].to_string(),
301
                base.add_modifier(modifier),
302
            ));
303
            i += mark + end + mark;
304
            continue;
305
        }
306
307
        if bytes[i] == b'[' {
308
            if let Some(link) = link_at(rest) {
309
                flush!();
310
                spans.push(Span::styled(
311
                    link.text.to_string(),
312
                    Style::default()
313
                        .fg(Color::Cyan)
314
                        .add_modifier(Modifier::UNDERLINED),
315
                ));
316
                spans.push(Span::styled(format!(" ({})", link.url), dim()));
317
                i += link.consumed;
318
                continue;
319
            }
320
        }
321
322
        let ch = rest.chars().next().expect("rest is non-empty");
323
        plain.push(ch);
324
        i += ch.len_utf8();
325
    }
326
    flush!();
327
    spans
328
}
329
330
struct Link<'a> {
331
    text: &'a str,
332
    url: &'a str,
333
    consumed: usize,
334
}
335
336
/// `[text](url)` at the head of `rest`.
337
fn link_at(rest: &str) -> Option<Link<'_>> {
338
    let close = rest.find("](")?;
339
    let url_start = close + 2;
340
    let url_end = url_start + rest[url_start..].find(')')?;
341
    let text = &rest[1..close];
342
    let url = &rest[url_start..url_end];
343
    if text.is_empty() || url.is_empty() || text.contains('[') {
344
        return None;
345
    }
346
    Some(Link {
347
        text,
348
        url,
349
        consumed: url_end + 1,
350
    })
351
}
352
353
// ------------------------------------------------------------- highlighting
354
355
/// What one language's lexer needs to know.
356
struct Syntax {
357
    keywords: &'static [&'static str],
358
    line_comment: &'static [&'static str],
359
    block_comment: Option<(&'static str, &'static str)>,
360
    quotes: &'static [char],
361
    /// A leading sigil that marks its word as a symbol: `:atom`, `@attr`, `$var`.
362
    sigils: &'static [char],
363
}
364
365
const RUST_KEYWORDS: &[&str] = &[
366
    "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
367
    "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
368
    "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
369
    "unsafe", "use", "where", "while",
370
];
371
const ELIXIR_KEYWORDS: &[&str] = &[
372
    "def",
373
    "defp",
374
    "defmodule",
375
    "defstruct",
376
    "defmacro",
377
    "defimpl",
378
    "defprotocol",
379
    "do",
380
    "end",
381
    "fn",
382
    "case",
383
    "cond",
384
    "if",
385
    "else",
386
    "unless",
387
    "with",
388
    "for",
389
    "receive",
390
    "try",
391
    "rescue",
392
    "after",
393
    "catch",
394
    "raise",
395
    "import",
396
    "alias",
397
    "require",
398
    "use",
399
    "when",
400
    "nil",
401
    "true",
402
    "false",
403
];
404
const PYTHON_KEYWORDS: &[&str] = &[
405
    "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif",
406
    "else", "except", "False", "finally", "for", "from", "global", "if", "import", "in", "is",
407
    "lambda", "None", "nonlocal", "not", "or", "pass", "raise", "return", "True", "try", "while",
408
    "with", "yield",
409
];
410
const JS_KEYWORDS: &[&str] = &[
411
    "async",
412
    "await",
413
    "break",
414
    "case",
415
    "catch",
416
    "class",
417
    "const",
418
    "continue",
419
    "default",
420
    "delete",
421
    "do",
422
    "else",
423
    "export",
424
    "extends",
425
    "false",
426
    "finally",
427
    "for",
428
    "from",
429
    "function",
430
    "if",
431
    "import",
432
    "in",
433
    "instanceof",
434
    "interface",
435
    "let",
436
    "new",
437
    "null",
438
    "of",
439
    "return",
440
    "static",
441
    "super",
442
    "switch",
443
    "this",
444
    "throw",
445
    "true",
446
    "try",
447
    "type",
448
    "typeof",
449
    "undefined",
450
    "var",
451
    "void",
452
    "while",
453
    "yield",
454
];
455
const GO_KEYWORDS: &[&str] = &[
456
    "break",
457
    "case",
458
    "chan",
459
    "const",
460
    "continue",
461
    "default",
462
    "defer",
463
    "else",
464
    "fallthrough",
465
    "for",
466
    "func",
467
    "go",
468
    "goto",
469
    "if",
470
    "import",
471
    "interface",
472
    "map",
473
    "package",
474
    "range",
475
    "return",
476
    "select",
477
    "struct",
478
    "switch",
479
    "type",
480
    "var",
481
    "nil",
482
    "true",
483
    "false",
484
];
485
const C_KEYWORDS: &[&str] = &[
486
    "auto", "break", "case", "char", "const", "continue", "default", "do", "double", "else",
487
    "enum", "extern", "float", "for", "goto", "if", "inline", "int", "long", "return", "short",
488
    "signed", "sizeof", "static", "struct", "switch", "typedef", "union", "unsigned", "void",
489
    "volatile", "while",
490
];
491
const SHELL_KEYWORDS: &[&str] = &[
492
    "case", "do", "done", "elif", "else", "esac", "export", "fi", "for", "function", "if", "in",
493
    "local", "return", "then", "until", "while",
494
];
495
const JSON_KEYWORDS: &[&str] = &["true", "false", "null"];
496
497
impl Syntax {
498
    fn for_language(language: &str) -> Self {
499
        match language {
500
            "rust" | "rs" => Syntax {
501
                keywords: RUST_KEYWORDS,
502
                line_comment: &["//"],
503
                block_comment: Some(("/*", "*/")),
504
                quotes: &['"'],
505
                sigils: &[],
506
            },
507
            "elixir" | "ex" | "exs" | "heex" => Syntax {
508
                keywords: ELIXIR_KEYWORDS,
509
                line_comment: &["#"],
510
                block_comment: None,
511
                quotes: &['"'],
512
                sigils: &[':', '@'],
513
            },
514
            "python" | "py" => Syntax {
515
                keywords: PYTHON_KEYWORDS,
516
                line_comment: &["#"],
517
                block_comment: None,
518
                quotes: &['"', '\''],
519
                sigils: &['@'],
520
            },
521
            "javascript" | "js" | "jsx" | "typescript" | "ts" | "tsx" => Syntax {
522
                keywords: JS_KEYWORDS,
523
                line_comment: &["//"],
524
                block_comment: Some(("/*", "*/")),
525
                quotes: &['"', '\'', '`'],
526
                sigils: &[],
527
            },
528
            "go" => Syntax {
529
                keywords: GO_KEYWORDS,
530
                line_comment: &["//"],
531
                block_comment: Some(("/*", "*/")),
532
                quotes: &['"', '`'],
533
                sigils: &[],
534
            },
535
            "c" | "cpp" | "c++" | "h" | "java" | "swift" | "kotlin" => Syntax {
536
                keywords: C_KEYWORDS,
537
                line_comment: &["//"],
538
                block_comment: Some(("/*", "*/")),
539
                quotes: &['"', '\''],
540
                sigils: &[],
541
            },
542
            "sh" | "bash" | "zsh" | "shell" | "console" => Syntax {
543
                keywords: SHELL_KEYWORDS,
544
                line_comment: &["#"],
545
                block_comment: None,
546
                quotes: &['"', '\''],
547
                sigils: &['$'],
548
            },
549
            "json" => Syntax {
550
                keywords: JSON_KEYWORDS,
551
                line_comment: &[],
552
                block_comment: None,
553
                quotes: &['"'],
554
                sigils: &[],
555
            },
556
            "toml" | "ini" => Syntax {
557
                keywords: &["true", "false"],
558
                line_comment: &["#"],
559
                block_comment: None,
560
                quotes: &['"', '\''],
561
                sigils: &[],
562
            },
563
            "yaml" | "yml" => Syntax {
564
                keywords: &["true", "false", "null"],
565
                line_comment: &["#"],
566
                block_comment: None,
567
                quotes: &['"', '\''],
568
                sigils: &[],
569
            },
570
            "sql" => Syntax {
571
                keywords: &[
572
                    "select", "from", "where", "insert", "into", "values", "update", "set",
573
                    "delete", "join", "left", "inner", "outer", "on", "group", "order", "by",
574
                    "limit", "create", "table", "index", "drop", "alter", "and", "or", "not",
575
                    "null", "as",
576
                ],
577
                line_comment: &["--"],
578
                block_comment: Some(("/*", "*/")),
579
                quotes: &['\'', '"'],
580
                sigils: &[],
581
            },
582
            // A language nothing here knows gets no invented colouring.
583
            _ => Syntax {
584
                keywords: &[],
585
                line_comment: &[],
586
                block_comment: None,
587
                quotes: &[],
588
                sigils: &[],
589
            },
590
        }
591
    }
592
}
593
594
fn keyword_style() -> Style {
595
    Style::default().fg(Color::Magenta)
596
}
597
fn string_style() -> Style {
598
    Style::default().fg(Color::Green)
599
}
600
fn comment_style() -> Style {
601
    dim().add_modifier(Modifier::ITALIC)
602
}
603
fn number_style() -> Style {
604
    Style::default().fg(Color::Yellow)
605
}
606
fn symbol_style() -> Style {
607
    Style::default().fg(Color::Cyan)
608
}
609
610
/// Lex one line. Returns its spans and whether a block comment is still open.
611
fn highlight(line: &str, syntax: &Syntax, mut in_block: bool) -> (Vec<Span<'static>>, bool) {
612
    let mut spans: Vec<Span<'static>> = Vec::new();
613
    let mut plain = String::new();
614
    let mut i = 0usize;
615
616
    macro_rules! flush {
617
        () => {
618
            if !plain.is_empty() {
619
                spans.push(Span::raw(std::mem::take(&mut plain)));
620
            }
621
        };
622
    }
623
624
    while i < line.len() {
625
        let rest = &line[i..];
626
627
        if in_block {
628
            let (_, close) = syntax
629
                .block_comment
630
                .expect("in_block implies a block syntax");
631
            match rest.find(close) {
632
                Some(end) => {
633
                    spans.push(Span::styled(
634
                        rest[..end + close.len()].to_string(),
635
                        comment_style(),
636
                    ));
637
                    i += end + close.len();
638
                    in_block = false;
639
                }
640
                None => {
641
                    spans.push(Span::styled(rest.to_string(), comment_style()));
642
                    i = line.len();
643
                }
644
            }
645
            continue;
646
        }
647
648
        if let Some((open, close)) = syntax.block_comment {
649
            if let Some(after_open) = rest.strip_prefix(open) {
650
                flush!();
651
                match after_open.find(close) {
652
                    Some(end) => {
653
                        let stop = open.len() + end + close.len();
654
                        spans.push(Span::styled(rest[..stop].to_string(), comment_style()));
655
                        i += stop;
656
                    }
657
                    None => {
658
                        spans.push(Span::styled(rest.to_string(), comment_style()));
659
                        i = line.len();
660
                        in_block = true;
661
                    }
662
                }
663
                continue;
664
            }
665
        }
666
667
        if let Some(marker) = syntax
668
            .line_comment
669
            .iter()
670
            .find(|marker| rest.starts_with(**marker))
671
        {
672
            let _ = marker;
673
            flush!();
674
            spans.push(Span::styled(rest.to_string(), comment_style()));
675
            break;
676
        }
677
678
        let ch = rest.chars().next().expect("rest is non-empty");
679
680
        if syntax.quotes.contains(&ch) {
681
            flush!();
682
            let (literal, consumed) = string_at(rest, ch);
683
            spans.push(Span::styled(literal, string_style()));
684
            i += consumed;
685
            continue;
686
        }
687
688
        if ch.is_ascii_digit() && !preceded_by_word(line, i) {
689
            let end = rest
690
                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '.' || c == '_'))
691
                .unwrap_or(rest.len());
692
            flush!();
693
            spans.push(Span::styled(rest[..end].to_string(), number_style()));
694
            i += end;
695
            continue;
696
        }
697
698
        if syntax.sigils.contains(&ch) {
699
            let end = rest[ch.len_utf8()..]
700
                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '?' || c == '!'))
701
                .map_or(rest.len(), |offset| ch.len_utf8() + offset);
702
            if end > ch.len_utf8() {
703
                flush!();
704
                spans.push(Span::styled(rest[..end].to_string(), symbol_style()));
705
                i += end;
706
                continue;
707
            }
708
        }
709
710
        if ch.is_alphabetic() || ch == '_' {
711
            let end = rest
712
                .find(|c: char| !(c.is_alphanumeric() || c == '_'))
713
                .unwrap_or(rest.len());
714
            let word = &rest[..end];
715
            if syntax.keywords.contains(&word) {
716
                flush!();
717
                spans.push(Span::styled(word.to_string(), keyword_style()));
718
            } else {
719
                plain.push_str(word);
720
            }
721
            i += end;
722
            continue;
723
        }
724
725
        plain.push(ch);
726
        i += ch.len_utf8();
727
    }
728
    flush!();
729
    (spans, in_block)
730
}
731
732
/// Whether the byte before `at` is part of a word, so `x2` is not a number.
733
fn preceded_by_word(line: &str, at: usize) -> bool {
734
    line[..at]
735
        .chars()
736
        .next_back()
737
        .is_some_and(|c| c.is_alphanumeric() || c == '_')
738
}
739
740
/// The string literal starting at `rest[0]`, and how many bytes it took.
741
///
742
/// An unterminated literal runs to the end of the line, which is what a
743
/// half-written line of a streaming reply looks like.
744
fn string_at(rest: &str, quote: char) -> (String, usize) {
745
    let mut escaped = false;
746
    for (offset, ch) in rest.char_indices().skip(1) {
747
        if escaped {
748
            escaped = false;
749
            continue;
750
        }
751
        if ch == '\\' {
752
            escaped = true;
753
            continue;
754
        }
755
        if ch == quote {
756
            let stop = offset + ch.len_utf8();
757
            return (rest[..stop].to_string(), stop);
758
        }
759
    }
760
    (rest.to_string(), rest.len())
761
}
762
763
// ------------------------------------------------------------------- layout
764
765
/// Soft-wrap styled spans to `width`, keeping each grapheme's style.
766
///
767
/// `hanging` is the number of columns a continuation row is indented by, so a
768
/// wrapped list item lines up under its own text rather than under its bullet.
769
/// `indent` is a left margin applied to every row, which is how a nested list
770
/// keeps its nesting.
771
pub fn wrap_spans(
772
    spans: Vec<Span<'static>>,
773
    width: usize,
774
    hanging: usize,
775
    indent: usize,
776
) -> Vec<Line<'static>> {
777
    let indent = indent.min(width.saturating_sub(2));
778
    let body = width.saturating_sub(indent).max(2);
779
    let hanging = hanging.min(body.saturating_sub(1));
780
781
    // Flatten to graphemes so a break can land anywhere and the style follows.
782
    let mut cells: Vec<(String, Style)> = Vec::new();
783
    for span in spans {
784
        for grapheme in span.content.graphemes(true) {
785
            cells.push((grapheme.to_string(), span.style));
786
        }
787
    }
788
    if cells.is_empty() {
789
        return vec![Line::from(" ".repeat(indent))];
790
    }
791
792
    let mut rows: Vec<Vec<(String, Style)>> = Vec::new();
793
    let mut row: Vec<(String, Style)> = Vec::new();
794
    let mut used = 0usize;
795
    let mut break_at: Option<(usize, usize)> = None;
796
    let mut limit = body;
797
798
    for (grapheme, style) in cells {
799
        let w = grapheme.width().max(1);
800
        if used + w > limit && !row.is_empty() {
801
            match break_at {
802
                Some((index, _)) if index > 0 && index < row.len() => {
803
                    let tail = row.split_off(index);
804
                    while row.last().is_some_and(|(g, _)| g == " ") {
805
                        row.pop();
806
                    }
807
                    rows.push(std::mem::take(&mut row));
808
                    row = tail.into_iter().skip_while(|(g, _)| g == " ").collect();
809
                }
810
                _ => rows.push(std::mem::take(&mut row)),
811
            }
812
            used = row.iter().map(|(g, _)| g.width().max(1)).sum();
813
            break_at = None;
814
            limit = body.saturating_sub(hanging).max(1);
815
        }
816
        if grapheme == " " {
817
            break_at = Some((row.len(), used));
818
        }
819
        row.push((grapheme, style));
820
        used += w;
821
    }
822
    rows.push(row);
823
824
    rows.into_iter()
825
        .enumerate()
826
        .map(|(index, row)| {
827
            let pad = indent + if index == 0 { 0 } else { hanging };
828
            let mut spans: Vec<Span<'static>> = Vec::new();
829
            if pad > 0 {
830
                spans.push(Span::raw(" ".repeat(pad)));
831
            }
832
            spans.extend(coalesce(row));
833
            Line::from(spans)
834
        })
835
        .collect()
836
}
837
838
/// Join neighbouring graphemes that share a style back into spans.
839
fn coalesce(cells: Vec<(String, Style)>) -> Vec<Span<'static>> {
840
    let mut spans: Vec<Span<'static>> = Vec::new();
841
    for (grapheme, style) in cells {
842
        match spans.last_mut() {
843
            Some(last) if last.style == style => last.content.to_mut().push_str(&grapheme),
844
            _ => spans.push(Span::styled(grapheme, style)),
845
        }
846
    }
847
    spans
848
}
849
850
/// Cut styled spans to `width` columns, marking the cut with an ellipsis.
851
pub fn truncate_spans(spans: Vec<Span<'static>>, width: usize) -> Vec<Span<'static>> {
852
    let total: usize = spans.iter().map(|span| span.content.width()).sum();
853
    if total <= width {
854
        return spans;
855
    }
856
    if width == 0 {
857
        return Vec::new();
858
    }
859
    let budget = width.saturating_sub(1);
860
    let mut out: Vec<Span<'static>> = Vec::new();
861
    let mut used = 0usize;
862
    for span in spans {
863
        if used >= budget {
864
            break;
865
        }
866
        let mut kept = String::new();
867
        for grapheme in span.content.graphemes(true) {
868
            let w = grapheme.width().max(1);
869
            if used + w > budget {
870
                break;
871
            }
872
            kept.push_str(grapheme);
873
            used += w;
874
        }
875
        if !kept.is_empty() {
876
            out.push(Span::styled(kept, span.style));
877
        }
878
    }
879
    out.push(Span::styled("…".to_string(), dim()));
880
    out
881
}
882
883
#[cfg(test)]
884
mod tests {
885
    use super::*;
886
887
    /// The text of a row, styles dropped.
888
    fn text(line: &Line<'_>) -> String {
889
        line.spans.iter().map(|s| s.content.as_ref()).collect()
890
    }
891
892
    fn texts(rows: &[Line<'_>]) -> Vec<String> {
893
        rows.iter().map(text).collect()
894
    }
895
896
    /// The style covering the first occurrence of `needle` in a row.
897
    fn style_of(line: &Line<'_>, needle: &str) -> Option<Style> {
898
        line.spans
899
            .iter()
900
            .find(|span| span.content.contains(needle))
901
            .map(|span| span.style)
902
    }
903
904
    #[test]
905
    fn bold_loses_its_asterisks_and_gains_the_modifier() {
906
        let rows = render("this is **important** text", 60);
907
        assert_eq!(texts(&rows), vec!["this is important text".to_string()]);
908
        let style = style_of(&rows[0], "important").expect("a span for the bold run");
909
        assert!(style.add_modifier.contains(Modifier::BOLD));
910
    }
911
912
    #[test]
913
    fn an_unclosed_bold_run_renders_as_the_characters_that_are_there() {
914
        // What half of a streamed chunk looks like. It must not swallow the
915
        // asterisks waiting for a closer that has not arrived.
916
        let rows = render("this is **import", 60);
917
        assert_eq!(texts(&rows), vec!["this is **import".to_string()]);
918
    }
919
920
    #[test]
921
    fn inline_code_is_coloured_and_loses_its_backticks() {
922
        let rows = render("run `mix precommit` first", 60);
923
        assert_eq!(texts(&rows), vec!["run mix precommit first".to_string()]);
924
        assert_eq!(
925
            style_of(&rows[0], "mix precommit").and_then(|s| s.fg),
926
            Some(Color::Yellow)
927
        );
928
    }
929
930
    #[test]
931
    fn a_heading_drops_its_hashes_and_is_bold_cyan() {
932
        let rows = render("## What changed", 60);
933
        assert_eq!(texts(&rows), vec!["What changed".to_string()]);
934
        let style = style_of(&rows[0], "What changed").expect("a heading span");
935
        assert_eq!(style.fg, Some(Color::Cyan));
936
        assert!(style.add_modifier.contains(Modifier::BOLD));
937
    }
938
939
    #[test]
940
    fn a_hash_without_a_space_is_not_a_heading() {
941
        let rows = render("#73 is the issue", 60);
942
        assert_eq!(texts(&rows), vec!["#73 is the issue".to_string()]);
943
    }
944
945
    #[test]
946
    fn a_bullet_becomes_a_bullet_and_wraps_under_its_own_text() {
947
        let rows = render("- alpha beta gamma delta", 14);
948
        assert_eq!(
949
            texts(&rows),
950
            vec!["• alpha beta".to_string(), "  gamma delta".to_string()]
951
        );
952
    }
953
954
    #[test]
955
    fn a_numbered_item_keeps_its_number() {
956
        let rows = render("3. third thing", 40);
957
        assert_eq!(texts(&rows), vec!["3. third thing".to_string()]);
958
    }
959
960
    #[test]
961
    fn a_link_shows_its_text_and_its_url() {
962
        let rows = render("see [the issue](https://openagents.com/i/73)", 80);
963
        assert_eq!(
964
            texts(&rows),
965
            vec!["see the issue (https://openagents.com/i/73)".to_string()]
966
        );
967
        let style = style_of(&rows[0], "the issue").expect("a link span");
968
        assert!(style.add_modifier.contains(Modifier::UNDERLINED));
969
    }
970
971
    #[test]
972
    fn a_fenced_block_gets_a_rail_and_end_caps() {
973
        let rows = render("```rust\nlet x = 1;\n```", 40);
974
        assert_eq!(
975
            texts(&rows),
976
            vec![
977
                "╭─ rust".to_string(),
978
                "│ let x = 1;".to_string(),
979
                "╰─".to_string(),
980
            ]
981
        );
982
    }
983
984
    #[test]
985
    fn a_fence_that_has_not_closed_yet_still_renders_its_code() {
986
        // The state a fenced block is in for every chunk but its last.
987
        let rows = render("```rust\nfn main() {", 40);
988
        assert_eq!(
989
            texts(&rows),
990
            vec!["╭─ rust".to_string(), "│ fn main() {".to_string()]
991
        );
992
    }
993
994
    #[test]
995
    fn rust_keywords_strings_and_comments_are_each_coloured_apart() {
996
        let rows = render("```rust\nlet s = \"hi\"; // note\n```", 60);
997
        let code = &rows[1];
998
        assert_eq!(
999
            style_of(code, "let").and_then(|s| s.fg),
1000
            Some(Color::Magenta)
1001
        );
1002
        assert_eq!(
1003
            style_of(code, "\"hi\"").and_then(|s| s.fg),
1004
            Some(Color::Green)
1005
        );
1006
        assert_eq!(
1007
            style_of(code, "// note").and_then(|s| s.fg),
1008
            Some(Color::DarkGray)
1009
        );
1010
    }
1011
1012
    #[test]
1013
    fn elixir_atoms_and_hash_comments_are_recognised() {
1014
        let rows = render("```elixir\ndef run, do: :ok # go\n```", 60);
1015
        let code = &rows[1];
1016
        assert_eq!(
1017
            style_of(code, "def").and_then(|s| s.fg),
1018
            Some(Color::Magenta)
1019
        );
1020
        assert_eq!(style_of(code, ":ok").and_then(|s| s.fg), Some(Color::Cyan));
1021
        assert_eq!(
1022
            style_of(code, "# go").and_then(|s| s.fg),
1023
            Some(Color::DarkGray)
1024
        );
1025
    }
1026
1027
    /// A `#` is a comment in Elixir and is not one in Rust. A highlighter that
1028
    /// used one comment rule everywhere would grey out the rest of this line.
1029
    #[test]
1030
    fn the_comment_rule_is_the_languages_own() {
1031
        let rows = render("```rust\nlet n = 1; # not a comment here\n```", 60);
1032
        assert!(
1033
            style_of(&rows[1], "# not").is_none_or(|s| s.fg != Some(Color::DarkGray)),
1034
            "a Rust line was greyed out from a `#`"
1035
        );
1036
    }
1037
1038
    #[test]
1039
    fn an_unknown_language_is_left_plain() {
1040
        let rows = render("```brainfuck\n+++[->+<]\n```", 40);
1041
        let plain = rows[1]
1042
            .spans
1043
            .iter()
1044
            .skip(1)
1045
            .all(|span| span.style == Style::default());
1046
        assert!(plain, "an unknown language was given invented colours");
1047
    }
1048
1049
    #[test]
1050
    fn a_code_line_too_wide_for_the_pane_is_cut_not_wrapped() {
1051
        let rows = render("```rust\nlet a_very_long_identifier = 1;\n```", 16);
1052
        assert_eq!(texts(&rows)[1], "│ let a_very_lo…");
1053
    }
1054
1055
    #[test]
1056
    fn a_block_comment_stays_open_across_rows() {
1057
        let rows = render("```rust\n/* one\ntwo */ let x = 1;\n```", 60);
1058
        assert_eq!(
1059
            style_of(&rows[2], "two */").and_then(|s| s.fg),
1060
            Some(Color::DarkGray)
1061
        );
1062
        assert_eq!(
1063
            style_of(&rows[2], "let").and_then(|s| s.fg),
1064
            Some(Color::Magenta)
1065
        );
1066
    }
1067
1068
    #[test]
1069
    fn a_quote_gets_a_rail() {
1070
        let rows = render("> quoted", 40);
1071
        assert_eq!(texts(&rows), vec!["▎ quoted".to_string()]);
1072
    }
1073
1074
    #[test]
1075
    fn a_rule_fills_the_width() {
1076
        let rows = render("---", 8);
1077
        assert_eq!(texts(&rows), vec!["────────".to_string()]);
1078
    }
1079
1080
    #[test]
1081
    fn wrapping_keeps_the_style_of_the_run_it_split() {
1082
        let rows = render("**alpha beta gamma delta epsilon**", 14);
1083
        assert_eq!(rows.len(), 3);
1084
        for row in &rows {
1085
            for span in &row.spans {
1086
                if span.content.trim().is_empty() {
1087
                    continue;
1088
                }
1089
                assert!(
1090
                    span.style.add_modifier.contains(Modifier::BOLD),
1091
                    "wrapping dropped the bold from {:?}",
1092
                    span.content
1093
                );
1094
            }
1095
        }
1096
    }
1097
1098
    #[test]
1099
    fn no_row_is_wider_than_the_width_it_was_given() {
1100
        let source = "A paragraph with a ridiculouslylongunbrokenidentifier in it, plus \
1101
                      `code` and **bold**.\n\n- a bullet that also needs to wrap somewhere\n\n\
1102
                      ```rust\nfn f() { let s = \"a string that is quite long indeed\"; }\n```";
1103
        for width in [8usize, 13, 20, 41, 80] {
1104
            for row in render(source, width) {
1105
                let drawn: usize = row.spans.iter().map(|s| s.content.width()).sum();
1106
                assert!(
1107
                    drawn <= width,
1108
                    "a row of {drawn} columns was drawn into {width}: {:?}",
1109
                    text(&row)
1110
                );
1111
            }
1112
        }
1113
    }
1114
1115
    #[test]
1116
    fn plain_prose_survives_a_round_trip_unchanged() {
1117
        let rows = render("just some ordinary words", 60);
1118
        assert_eq!(texts(&rows), vec!["just some ordinary words".to_string()]);
1119
    }
1120
}
crates/openagents-cli/src/pty.rs added +753

@@ -0,0 +1,753 @@

1
//! Running a command under a pseudoterminal, inside the coder frame.
2
//!
3
//! Adapted from `ptyctl` in grok-build, which is Apache-2.0 and is what
4
//! `crates/openagents-cli/src/composer/LICENSE-APACHE-xai` covers. What is
5
//! taken is the shape: `portable-pty` for the terminal pair, the master
6
//! dismantled into a reader thread, a writer, and a resize handle, and the
7
//! child's killer cloned off so it can be stopped without waiting on it. What
8
//! is not taken is grok's websocket
9
//! server, its session registry, or its rendering — output here lands in the
10
//! OpenAgents box frame.
11
//!
12
//! ## Why a pseudoterminal and not a pipe
13
//!
14
//! A pipe is not a terminal. A program asked whether its output is a terminal
15
//! answers no, and the ones worth watching change what they do: `git` drops
16
//! its colour, `top` and `vim` refuse to draw at all, and anything that reads
17
//! its width from the kernel gets nothing to read. A pseudoterminal answers
18
//! yes, carries a window size the child can ask for, and delivers `SIGWINCH`
19
//! when that size changes. [`PtySession::resize`] is what makes the last of
20
//! those true, and it is wired to the frame's own size.
21
//!
22
//! ## The pieces
23
//!
24
//! - [`PtySession`] owns the child and the terminal pair. It is I/O, so it is
25
//!   built only where there is a real terminal.
26
//! - [`PtyScreen`] is a terminal emulator over the bytes that come back: it
27
//!   holds the grid the child has drawn and renders it into the frame. It is
28
//!   pure — bytes in, cells out — so the tests below drive it directly.
29
//! - [`encode_key`] turns a key the frame received into the bytes a terminal
30
//!   would have sent for it.
31
32
use std::io::{Read, Write};
33
use std::path::PathBuf;
34
use std::sync::{Arc, Mutex};
35
36
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
37
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
38
use ratatui::buffer::Buffer;
39
use ratatui::layout::{Position, Rect};
40
use ratatui::style::{Color, Modifier, Style};
41
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver};
42
43
/// The key that takes the keyboard back from a running child.
44
///
45
/// Everything else goes to the child, including Esc and Ctrl+C, because a
46
/// full-screen program needs both. `Ctrl+]` is the telnet escape and is not
47
/// bound by the shells or editors this is likely to be running.
48
pub const DETACH: KeyEvent = KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL);
49
pub const DETACH_LABEL: &str = "Ctrl+]";
50
51
/// What a running child sends back.
52
#[derive(Debug)]
53
pub enum PtyEvent {
54
    /// Bytes the child wrote. Not necessarily whole lines or whole sequences.
55
    Output(Vec<u8>),
56
    /// The child ended, with its exit code.
57
    Exit(u32),
58
}
59
60
/// What the frame can do to a running child.
61
///
62
/// A trait rather than a struct so a test can drive the pane with a recording
63
/// stand-in and assert that a resize actually reached the child's side.
64
pub trait PtyControl: Send + Sync + std::fmt::Debug {
65
    /// Send bytes to the child's input.
66
    fn write(&self, bytes: &[u8]);
67
    /// Tell the child its terminal is now this size.
68
    fn resize(&self, cols: u16, rows: u16);
69
    /// End the child.
70
    fn kill(&self);
71
}
72
73
/// A command running under a pseudoterminal.
74
pub struct PtySession {
75
    master: Mutex<Box<dyn MasterPty + Send>>,
76
    writer: Mutex<Box<dyn Write + Send>>,
77
    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
78
}
79
80
impl PtySession {
81
    /// Start `command` under a pseudoterminal of `cols` × `rows`.
82
    ///
83
    /// Returns the handle the frame keeps and the stream of what the child
84
    /// writes. A thread is started rather than a task because reading a
85
    /// terminal blocks on a file descriptor that cannot be polled.
86
    pub fn spawn(
87
        command: &[String],
88
        cwd: Option<PathBuf>,
89
        cols: u16,
90
        rows: u16,
91
    ) -> std::io::Result<(Arc<PtySession>, UnboundedReceiver<PtyEvent>)> {
92
        let Some(program) = command.first() else {
93
            return Err(oops("no command to run"));
94
        };
95
96
        let pair = native_pty_system()
97
            .openpty(PtySize {
98
                rows: rows.max(1),
99
                cols: cols.max(1),
100
                pixel_width: 0,
101
                pixel_height: 0,
102
            })
103
            .map_err(|error| oops(&format!("could not open a pseudoterminal: {error}")))?;
104
105
        let mut builder = CommandBuilder::new(program);
106
        builder.args(&command[1..]);
107
        if let Some(cwd) = cwd {
108
            builder.cwd(cwd);
109
        }
110
        // The child is told it is on a terminal that can do colour. It has
111
        // one: `PtyScreen` renders every attribute this claims.
112
        builder.env("TERM", "xterm-256color");
113
        // The reader's `--no-color` reaches the child the same way it reaches
114
        // a delegated harness.
115
        if !crate::diag::color() {
116
            builder.env("NO_COLOR", "1");
117
        }
118
119
        // Whether the command exists is worth answering before spawning it.
120
        // The failure the reader will hit most often is a typo, and the error
121
        // the operating system hands back through `portable-pty` arrives
122
        // wrapped in a debug print of the whole command — the entire inherited
123
        // environment included, thousands of characters of `PATH` between the
124
        // reader and the two words that matter.
125
        if find_program(program).is_none() {
126
            return Err(oops(&format!(
127
                "could not start `{program}`: there is no such command on this machine"
128
            )));
129
        }
130
131
        let mut child = pair
132
            .slave
133
            .spawn_command(builder)
134
            .map_err(|error| oops(&format!("could not start `{program}`: {}", brief(&error))))?;
135
        let killer = child.clone_killer();
136
137
        let mut reader = pair.master.try_clone_reader().map_err(|error| {
138
            oops(&format!(
139
                "could not read the pseudoterminal: {}",
140
                brief(&error)
141
            ))
142
        })?;
143
        let writer = pair.master.take_writer().map_err(|error| {
144
            oops(&format!(
145
                "could not write the pseudoterminal: {}",
146
                brief(&error)
147
            ))
148
        })?;
149
150
        let (tx, rx) = unbounded_channel();
151
152
        // One thread reads and then waits, rather than two racing to report.
153
        // The last thing a program writes is often the whole answer, and a
154
        // separate waiter can win the race to the channel and close the pane
155
        // over the top of output that has not been delivered yet.
156
        std::thread::spawn(move || {
157
            let mut buffer = [0u8; 8192];
158
            loop {
159
                match reader.read(&mut buffer) {
160
                    Ok(0) | Err(_) => break,
161
                    Ok(n) => {
162
                        if tx.send(PtyEvent::Output(buffer[..n].to_vec())).is_err() {
163
                            return;
164
                        }
165
                    }
166
                }
167
            }
168
            let code = child.wait().map(|status| status.exit_code()).unwrap_or(1);
169
            let _ = tx.send(PtyEvent::Exit(code));
170
        });
171
172
        Ok((
173
            Arc::new(PtySession {
174
                master: Mutex::new(pair.master),
175
                writer: Mutex::new(writer),
176
                killer: Mutex::new(killer),
177
            }),
178
            rx,
179
        ))
180
    }
181
}
182
183
impl std::fmt::Debug for PtySession {
184
    /// The handles inside are a terminal pair and a process; none of them has
185
    /// a useful debug form, and the identity of the session is all a caller
186
    /// printing a message about one needs.
187
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188
        f.write_str("PtySession")
189
    }
190
}
191
192
impl PtyControl for PtySession {
193
    fn write(&self, bytes: &[u8]) {
194
        if let Ok(mut writer) = self.writer.lock() {
195
            let _ = writer.write_all(bytes);
196
            let _ = writer.flush();
197
        }
198
    }
199
200
    fn resize(&self, cols: u16, rows: u16) {
201
        if let Ok(master) = self.master.lock() {
202
            let _ = master.resize(PtySize {
203
                rows: rows.max(1),
204
                cols: cols.max(1),
205
                pixel_width: 0,
206
                pixel_height: 0,
207
            });
208
        }
209
    }
210
211
    fn kill(&self) {
212
        if let Ok(mut killer) = self.killer.lock() {
213
            let _ = killer.kill();
214
        }
215
    }
216
}
217
218
fn oops(message: &str) -> std::io::Error {
219
    std::io::Error::other(message.to_string())
220
}
221
222
/// As much of an error as belongs on one line of a transcript.
223
fn brief(error: impl std::fmt::Display) -> String {
224
    let text = error.to_string();
225
    let line = text.lines().next().unwrap_or_default().trim();
226
    if line.chars().count() <= 160 {
227
        return line.to_string();
228
    }
229
    format!("{}…", line.chars().take(160).collect::<String>())
230
}
231
232
/// Where `program` would be found, or `None` if it would not be.
233
///
234
/// A name with a separator in it is a path and is taken as one; anything else
235
/// is looked for on `PATH`, which is the same rule the spawn itself follows.
236
pub fn find_program(program: &str) -> Option<PathBuf> {
237
    let runnable = |path: &std::path::Path| path.is_file();
238
    if program.contains('/') {
239
        let path = PathBuf::from(program);
240
        return runnable(&path).then_some(path);
241
    }
242
    std::env::var_os("PATH")
243
        .map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
244
        .unwrap_or_default()
245
        .into_iter()
246
        .map(|directory| directory.join(program))
247
        .find(|candidate| runnable(candidate))
248
}
249
250
/// Split a command line into words, honouring quotes.
251
///
252
/// `/run git log --oneline -n 5` is words. `/run echo "one two"` is three.
253
/// Anything past that — pipes, redirection, globbing — belongs to a shell, and
254
/// [`shell_command`] is how a caller asks for one.
255
pub fn split_command(line: &str) -> Vec<String> {
256
    let mut words = Vec::new();
257
    let mut word = String::new();
258
    let mut quote: Option<char> = None;
259
    let mut any = false;
260
261
    for ch in line.chars() {
262
        match quote {
263
            Some(q) if ch == q => quote = None,
264
            Some(_) => word.push(ch),
265
            None if ch == '\'' || ch == '"' => {
266
                quote = Some(ch);
267
                any = true;
268
            }
269
            None if ch.is_whitespace() => {
270
                if !word.is_empty() || any {
271
                    words.push(std::mem::take(&mut word));
272
                    any = false;
273
                }
274
            }
275
            None => word.push(ch),
276
        }
277
    }
278
    if !word.is_empty() || any {
279
        words.push(word);
280
    }
281
    words
282
}
283
284
/// Whether `line` needs a shell to mean what it says.
285
pub fn needs_a_shell(line: &str) -> bool {
286
    line.contains(['|', '>', '<', '&', ';', '*', '$', '`', '('])
287
}
288
289
/// The command that runs `line` under this machine's shell.
290
pub fn shell_command(line: &str) -> Vec<String> {
291
    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
292
    vec![shell, "-c".to_string(), line.to_string()]
293
}
294
295
// ------------------------------------------------------------------ screen
296
297
/// The grid a child has drawn, and the renderer for it.
298
pub struct PtyScreen {
299
    parser: vt100::Parser,
300
    cols: u16,
301
    rows: u16,
302
}
303
304
/// The smallest emulated screen this will build.
305
///
306
/// `vt100` subtracts a character's width from the screen's width when it
307
/// decides whether to wrap, and subtracts one from the row count when it
308
/// scrolls; on a screen one row or one column across, both underflow and the
309
/// process aborts. A terminal emulator crashing because its pane got small is
310
/// not a trade worth making, so nothing narrower than this is ever built and
311
/// the frame clips instead.
312
///
313
/// This is not hypothetical: a frame whose terminal reports no size at all —
314
/// which is what a pseudoterminal opened without a window size does, and what
315
/// `expect` hands a program by default — lands exactly here.
316
pub const MIN_SCREEN: u16 = 2;
317
318
impl PtyScreen {
319
    pub fn new(cols: u16, rows: u16) -> Self {
320
        let cols = cols.max(MIN_SCREEN);
321
        let rows = rows.max(MIN_SCREEN);
322
        Self {
323
            // No scrollback: the pane shows what the child currently has on
324
            // its screen, which is what a terminal of this size would show.
325
            parser: vt100::Parser::new(rows, cols, 0),
326
            cols,
327
            rows,
328
        }
329
    }
330
331
    pub fn size(&self) -> (u16, u16) {
332
        (self.cols, self.rows)
333
    }
334
335
    pub fn feed(&mut self, bytes: &[u8]) {
336
        self.parser.process(bytes);
337
    }
338
339
    /// Resize the emulated screen. Returns whether the size actually changed,
340
    /// so the caller only pays for the `SIGWINCH` when there is one to send.
341
    pub fn resize(&mut self, cols: u16, rows: u16) -> bool {
342
        let cols = cols.max(MIN_SCREEN);
343
        let rows = rows.max(MIN_SCREEN);
344
        if (cols, rows) == (self.cols, self.rows) {
345
            return false;
346
        }
347
        self.cols = cols;
348
        self.rows = rows;
349
        self.parser.set_size(rows, cols);
350
        true
351
    }
352
353
    /// The title the child set with an `OSC 0` sequence, if it set one.
354
    pub fn title(&self) -> Option<&str> {
355
        let title = self.parser.screen().title();
356
        (!title.is_empty()).then_some(title)
357
    }
358
359
    /// Whether the child is asking for the arrow keys in application mode.
360
    pub fn application_cursor(&self) -> bool {
361
        self.parser.screen().application_cursor()
362
    }
363
364
    /// Where the child's own cursor sits, as (column, row) within the pane.
365
    pub fn cursor(&self) -> Option<(u16, u16)> {
366
        let screen = self.parser.screen();
367
        if screen.hide_cursor() {
368
            return None;
369
        }
370
        let (row, col) = screen.cursor_position();
371
        Some((col, row))
372
    }
373
374
    /// The screen as text, one row per line, trailing blanks trimmed.
375
    ///
376
    /// What a test asserts against, and what a reader would see if the frame
377
    /// carried no colour.
378
    pub fn text(&self) -> String {
379
        let screen = self.parser.screen();
380
        (0..self.rows)
381
            .map(|row| {
382
                let line: String = (0..self.cols)
383
                    .map(|col| {
384
                        screen
385
                            .cell(row, col)
386
                            .map(vt100::Cell::contents)
387
                            .filter(|c| !c.is_empty())
388
                            .unwrap_or_else(|| " ".to_string())
389
                    })
390
                    .collect();
391
                line.trim_end().to_string()
392
            })
393
            .collect::<Vec<_>>()
394
            .join("\n")
395
    }
396
397
    /// Paint the child's grid into `area`.
398
    pub fn render(&self, area: Rect, buffer: &mut Buffer) {
399
        let screen = self.parser.screen();
400
        for row in 0..area.height.min(self.rows) {
401
            for col in 0..area.width.min(self.cols) {
402
                let Some(cell) = screen.cell(row, col) else {
403
                    continue;
404
                };
405
                // A wide character's second half is drawn by the first; a cell
406
                // written over it would cut the glyph in two.
407
                if cell.is_wide_continuation() {
408
                    continue;
409
                }
410
                let Some(target) = buffer.cell_mut(Position::new(area.x + col, area.y + row))
411
                else {
412
                    continue;
413
                };
414
                let contents = cell.contents();
415
                target.set_symbol(if contents.is_empty() { " " } else { &contents });
416
                let mut style = Style::default()
417
                    .fg(convert(cell.fgcolor()))
418
                    .bg(convert(cell.bgcolor()));
419
                if cell.bold() {
420
                    style = style.add_modifier(Modifier::BOLD);
421
                }
422
                if cell.italic() {
423
                    style = style.add_modifier(Modifier::ITALIC);
424
                }
425
                if cell.underline() {
426
                    style = style.add_modifier(Modifier::UNDERLINED);
427
                }
428
                if cell.inverse() {
429
                    style = style.add_modifier(Modifier::REVERSED);
430
                }
431
                target.set_style(style);
432
            }
433
        }
434
    }
435
}
436
437
/// A colour the child asked for, as a colour ratatui can draw.
438
fn convert(color: vt100::Color) -> Color {
439
    match color {
440
        vt100::Color::Default => Color::Reset,
441
        vt100::Color::Idx(0) => Color::Black,
442
        vt100::Color::Idx(1) => Color::Red,
443
        vt100::Color::Idx(2) => Color::Green,
444
        vt100::Color::Idx(3) => Color::Yellow,
445
        vt100::Color::Idx(4) => Color::Blue,
446
        vt100::Color::Idx(5) => Color::Magenta,
447
        vt100::Color::Idx(6) => Color::Cyan,
448
        vt100::Color::Idx(7) => Color::Gray,
449
        vt100::Color::Idx(8) => Color::DarkGray,
450
        vt100::Color::Idx(9) => Color::LightRed,
451
        vt100::Color::Idx(10) => Color::LightGreen,
452
        vt100::Color::Idx(11) => Color::LightYellow,
453
        vt100::Color::Idx(12) => Color::LightBlue,
454
        vt100::Color::Idx(13) => Color::LightMagenta,
455
        vt100::Color::Idx(14) => Color::LightCyan,
456
        vt100::Color::Idx(15) => Color::White,
457
        vt100::Color::Idx(n) => Color::Indexed(n),
458
        vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
459
    }
460
}
461
462
// --------------------------------------------------------------------- keys
463
464
/// The bytes a terminal would send for `key`, or `None` for a key it has no
465
/// encoding for.
466
///
467
/// `application` is the child's own cursor-key mode, which a full-screen
468
/// program sets and which changes what the arrow keys are: `ESC O A` rather
469
/// than `ESC [ A`. Sending the wrong one is how arrow keys stop working inside
470
/// an editor while working fine at a shell prompt.
471
pub fn encode_key(key: &KeyEvent, application: bool) -> Option<Vec<u8>> {
472
    let control = key.modifiers.contains(KeyModifiers::CONTROL);
473
    let alt = key.modifiers.contains(KeyModifiers::ALT);
474
475
    let arrow = |final_byte: u8| {
476
        let lead = if application { b'O' } else { b'[' };
477
        Some(vec![0x1b, lead, final_byte])
478
    };
479
480
    let body = match key.code {
481
        KeyCode::Char(c) if control => {
482
            let byte = match c.to_ascii_lowercase() {
483
                c @ 'a'..='z' => c as u8 - b'a' + 1,
484
                ' ' | '@' => 0,
485
                '[' => 27,
486
                '\\' => 28,
487
                ']' => 29,
488
                '^' => 30,
489
                '_' | '?' => 31,
490
                _ => return None,
491
            };
492
            vec![byte]
493
        }
494
        KeyCode::Char(c) => {
495
            let mut bytes = Vec::new();
496
            let mut buffer = [0u8; 4];
497
            bytes.extend_from_slice(c.encode_utf8(&mut buffer).as_bytes());
498
            bytes
499
        }
500
        // A terminal sends carriage return for Enter; the line discipline is
501
        // what turns it into a newline. Sending `\n` skips that and looks to
502
        // the child like a literal line feed.
503
        KeyCode::Enter => vec![b'\r'],
504
        KeyCode::Tab => vec![b'\t'],
505
        KeyCode::BackTab => return Some(vec![0x1b, b'[', b'Z']),
506
        KeyCode::Backspace => vec![0x7f],
507
        KeyCode::Esc => vec![0x1b],
508
        KeyCode::Up => return prefix(arrow(b'A'), alt),
509
        KeyCode::Down => return prefix(arrow(b'B'), alt),
510
        KeyCode::Right => return prefix(arrow(b'C'), alt),
511
        KeyCode::Left => return prefix(arrow(b'D'), alt),
512
        KeyCode::Home => return prefix(arrow(b'H'), alt),
513
        KeyCode::End => return prefix(arrow(b'F'), alt),
514
        KeyCode::Insert => return Some(b"\x1b[2~".to_vec()),
515
        KeyCode::Delete => return Some(b"\x1b[3~".to_vec()),
516
        KeyCode::PageUp => return Some(b"\x1b[5~".to_vec()),
517
        KeyCode::PageDown => return Some(b"\x1b[6~".to_vec()),
518
        KeyCode::F(n @ 1..=4) => return Some(vec![0x1b, b'O', b'P' + (n - 1)]),
519
        KeyCode::F(n @ 5..=12) => {
520
            // The gaps in this table are the ones DEC left; they are not a
521
            // mistake being copied.
522
            let code = match n {
523
                5 => 15,
524
                6..=10 => 17 + (n - 6),
525
                11 => 23,
526
                _ => 24,
527
            };
528
            return Some(format!("\x1b[{code}~").into_bytes());
529
        }
530
        _ => return None,
531
    };
532
    prefix(Some(body), alt)
533
}
534
535
/// Alt is sent as an escape before the key, which is what a terminal does.
536
fn prefix(bytes: Option<Vec<u8>>, alt: bool) -> Option<Vec<u8>> {
537
    let bytes = bytes?;
538
    if !alt {
539
        return Some(bytes);
540
    }
541
    let mut out = vec![0x1b];
542
    out.extend(bytes);
543
    Some(out)
544
}
545
546
#[cfg(test)]
547
mod tests {
548
    use super::*;
549
550
    fn screen_of(bytes: &[u8], cols: u16, rows: u16) -> PtyScreen {
551
        let mut screen = PtyScreen::new(cols, rows);
552
        screen.feed(bytes);
553
        screen
554
    }
555
556
    #[test]
557
    fn plain_output_lands_on_the_grid() {
558
        let screen = screen_of(b"hello\r\nworld", 20, 4);
559
        assert_eq!(screen.text(), "hello\nworld\n\n");
560
    }
561
562
    #[test]
563
    fn a_cursor_move_puts_text_where_the_child_asked_for_it() {
564
        // `ESC [ 3 ; 5 H` is row three, column five.
565
        let screen = screen_of(b"\x1b[3;5Hhere", 20, 3);
566
        assert_eq!(screen.text(), "\n\n    here");
567
    }
568
569
    #[test]
570
    fn a_clear_takes_the_screen_back() {
571
        let screen = screen_of(b"old text\x1b[2J\x1b[Hnew", 20, 3);
572
        assert_eq!(screen.text(), "new\n\n");
573
    }
574
575
    #[test]
576
    fn colour_the_child_asked_for_is_the_colour_that_is_drawn() {
577
        let screen = screen_of(b"\x1b[31mred\x1b[0m plain", 20, 2);
578
        let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 2));
579
        screen.render(Rect::new(0, 0, 20, 2), &mut buffer);
580
        assert_eq!(buffer[(0, 0)].fg, Color::Red);
581
        assert_eq!(buffer[(0, 0)].symbol(), "r");
582
        assert_eq!(buffer[(4, 0)].fg, Color::Reset);
583
    }
584
585
    #[test]
586
    fn bold_and_reverse_survive_the_trip_to_the_frame() {
587
        let screen = screen_of(b"\x1b[1mB\x1b[0m\x1b[7mR", 8, 1);
588
        let mut buffer = Buffer::empty(Rect::new(0, 0, 8, 1));
589
        screen.render(Rect::new(0, 0, 8, 1), &mut buffer);
590
        assert!(buffer[(0, 0)].modifier.contains(Modifier::BOLD));
591
        assert!(buffer[(1, 0)].modifier.contains(Modifier::REVERSED));
592
    }
593
594
    /// A pane too small to emulate does not take the process down with it.
595
    ///
596
    /// `vt100` underflows on a screen one row or one column across, and a
597
    /// terminal that reports no size at all — a pseudoterminal opened without
598
    /// a window size, which is what `expect` gives a program — asks for
599
    /// exactly that. This is the regression test for a real crash: the
600
    /// session aborted inside its own alternate screen the first time it was
601
    /// driven under one.
602
    #[test]
603
    fn a_pane_too_small_to_emulate_does_not_bring_the_session_down() {
604
        for (cols, rows) in [(0u16, 0u16), (1, 1), (1, 40), (40, 1), (2, 1)] {
605
            let mut screen = PtyScreen::new(cols, rows);
606
            screen.feed("a long line of output that must wrap, plus 漢字\r\n\thi\r\n".as_bytes());
607
            let (c, r) = screen.size();
608
            assert!(c >= 2 && r >= 2, "a {c}×{r} screen was built");
609
            let mut buffer = Buffer::empty(Rect::new(0, 0, 4, 2));
610
            screen.render(Rect::new(0, 0, 4, 2), &mut buffer);
611
        }
612
613
        // And the same on the way down from a workable size.
614
        let mut screen = PtyScreen::new(80, 24);
615
        screen.feed(b"hello");
616
        screen.resize(1, 1);
617
        screen.feed(b"still here without aborting");
618
        assert_eq!(screen.size(), (2, 2));
619
    }
620
621
    #[test]
622
    fn a_resize_is_reported_only_when_the_size_actually_changed() {
623
        let mut screen = PtyScreen::new(20, 5);
624
        assert!(!screen.resize(20, 5));
625
        assert!(screen.resize(30, 5));
626
        assert_eq!(screen.size(), (30, 5));
627
    }
628
629
    #[test]
630
    fn the_pane_is_painted_at_the_offset_it_was_given() {
631
        let screen = screen_of(b"ab", 4, 1);
632
        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
633
        screen.render(Rect::new(3, 1, 4, 1), &mut buffer);
634
        assert_eq!(buffer[(3, 1)].symbol(), "a");
635
        assert_eq!(buffer[(4, 1)].symbol(), "b");
636
        // Outside the pane nothing was touched.
637
        assert_eq!(buffer[(0, 0)].symbol(), " ");
638
    }
639
640
    #[test]
641
    fn a_title_the_child_set_is_reported_and_an_unset_one_is_not() {
642
        assert_eq!(screen_of(b"nothing", 10, 1).title(), None);
643
        assert_eq!(
644
            screen_of(b"\x1b]0;a title\x07", 10, 1).title(),
645
            Some("a title")
646
        );
647
    }
648
649
    // ----------------------------------------------------------------- keys
650
651
    fn press(code: KeyCode) -> KeyEvent {
652
        KeyEvent::new(code, KeyModifiers::NONE)
653
    }
654
655
    #[test]
656
    fn enter_is_a_carriage_return_not_a_line_feed() {
657
        assert_eq!(
658
            encode_key(&press(KeyCode::Enter), false),
659
            Some(b"\r".to_vec())
660
        );
661
    }
662
663
    #[test]
664
    fn control_letters_become_their_control_bytes() {
665
        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
666
        assert_eq!(encode_key(&ctrl_c, false), Some(vec![3]));
667
        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
668
        assert_eq!(encode_key(&ctrl_d, false), Some(vec![4]));
669
    }
670
671
    #[test]
672
    fn the_arrow_keys_follow_the_childs_own_cursor_mode() {
673
        assert_eq!(
674
            encode_key(&press(KeyCode::Up), false),
675
            Some(b"\x1b[A".to_vec())
676
        );
677
        assert_eq!(
678
            encode_key(&press(KeyCode::Up), true),
679
            Some(b"\x1bOA".to_vec())
680
        );
681
    }
682
683
    #[test]
684
    fn alt_is_sent_as_an_escape_before_the_key() {
685
        let alt_b = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT);
686
        assert_eq!(encode_key(&alt_b, false), Some(vec![0x1b, b'b']));
687
    }
688
689
    #[test]
690
    fn backspace_is_delete_which_is_what_terminals_send() {
691
        assert_eq!(
692
            encode_key(&press(KeyCode::Backspace), false),
693
            Some(vec![0x7f])
694
        );
695
    }
696
697
    #[test]
698
    fn a_key_with_no_terminal_encoding_sends_nothing() {
699
        assert_eq!(encode_key(&press(KeyCode::CapsLock), false), None);
700
    }
701
702
    #[test]
703
    fn a_multibyte_character_is_sent_as_its_utf8() {
704
        assert_eq!(
705
            encode_key(&press(KeyCode::Char('é')), false),
706
            Some("é".as_bytes().to_vec())
707
        );
708
    }
709
710
    // ------------------------------------------------------------- commands
711
712
    #[test]
713
    fn a_command_line_splits_into_words_and_quotes_hold_together() {
714
        assert_eq!(
715
            split_command("git log --oneline -n 5"),
716
            vec!["git", "log", "--oneline", "-n", "5"]
717
        );
718
        assert_eq!(split_command("echo \"one two\""), vec!["echo", "one two"]);
719
        assert_eq!(split_command("  "), Vec::<String>::new());
720
        // An empty quoted word is still a word.
721
        assert_eq!(split_command("echo ''"), vec!["echo", ""]);
722
    }
723
724
    #[test]
725
    fn a_program_that_is_not_installed_is_reported_before_it_is_spawned() {
726
        assert!(find_program("sh").is_some(), "`sh` is on every PATH");
727
        assert!(find_program("this-command-does-not-exist-anywhere").is_none());
728
        // A name with a separator is a path, and is not looked for on PATH.
729
        assert!(find_program("./this-is-not-here").is_none());
730
    }
731
732
    /// The operating system's message about a failed spawn arrives wrapped in
733
    /// a debug print of the whole command, environment included. One line, and
734
    /// a bounded one, is what belongs on a transcript.
735
    #[test]
736
    fn an_error_is_cut_to_one_bounded_line() {
737
        assert_eq!(brief("short and single"), "short and single");
738
        assert_eq!(brief("first line\nsecond line"), "first line");
739
        let long = "x".repeat(400);
740
        let cut = brief(&long);
741
        assert_eq!(cut.chars().count(), 161);
742
        assert!(cut.ends_with('…'));
743
    }
744
745
    #[test]
746
    fn a_line_a_shell_would_change_the_meaning_of_is_given_to_a_shell() {
747
        assert!(needs_a_shell("ls | wc -l"));
748
        assert!(needs_a_shell("echo $HOME"));
749
        assert!(!needs_a_shell("git status"));
750
        assert_eq!(shell_command("ls | wc -l").len(), 3);
751
        assert_eq!(shell_command("ls").last().map(String::as_str), Some("ls"));
752
    }
753
}
crates/openagents-cli/src/tui.rs modified +325 -51

@@ -9,10 +9,17 @@

9 9
//! grammar: one bullet per turn in a four-column gutter, a colour per role, and
10 10
//! a `›` composer under a rule.
11 11
//!
12
//! The middle of the frame is one of three panes — the transcript, the diff
13
//! inspector, or a program running under a pseudoterminal — and the frame
14
//! around it does not change between them. Which pane is showing decides which
15
//! keys the status bar offers, because the keys are different in each and a bar
16
//! that named all of them would be naming keys that do nothing where you are.
17
//!
12 18
//! This module owns only the drawing. It holds no session state, so every
13 19
//! frame it produces is a function of the view it is handed, which is what
14 20
//! makes the frames assertable in a test.
15 21
22
use ratatui::buffer::Buffer;
16 23
use ratatui::{
17 24
    layout::{Constraint, Direction, Layout, Position, Rect},
18 25
    style::{Color, Modifier, Style},

@@ -20,9 +27,12 @@ use ratatui::{

20 27
    widgets::{Block, Borders, Paragraph},
21 28
    Frame,
22 29
};
23
use ratatui::buffer::Buffer;
24 30
use unicode_width::UnicodeWidthStr;
25 31
32
use crate::diff::DiffMode;
33
use crate::pty::PtyScreen;
34
use crate::runtime::TurnUsage;
35
26 36
/// Reset every foreground and background in an area to the terminal's own.
27 37
///
28 38
/// What `--no-color` does. It runs over the finished buffer, so it covers

@@ -49,6 +59,13 @@ pub const BULLET_PULSE: &str = "○";

49 59
/// The composer's prompt.
50 60
pub const PROMPT: &str = "› ";
51 61
62
/// Rows above and below the middle pane that the frame always keeps: the
63
/// header, the status bar, and the middle pane's own two rules.
64
const CHROME_ROWS: u16 = 3 + 3 + 2;
65
66
/// The smallest pane a program is ever told it has. See [`pty_viewport`].
67
const MIN_PANE: u16 = crate::pty::MIN_SCREEN;
68
52 69
/// Who wrote a transcript entry.
53 70
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54 71
pub enum Role {

@@ -72,6 +89,15 @@ impl Role {

72 89
            Role::Error => Color::Red,
73 90
        }
74 91
    }
92
93
    /// Whether what this role writes is markdown.
94
    ///
95
    /// Only the model's is. A notice this program wrote is already the shape
96
    /// it wants to be read in, and running it through a markdown renderer
97
    /// would mean a path with an underscore in it came out italic.
98
    pub fn is_markdown(self) -> bool {
99
        matches!(self, Role::Assistant)
100
    }
75 101
}
76 102
77 103
/// One turn on the transcript.

@@ -104,16 +130,61 @@ impl Entry {

104 130
    }
105 131
}
106 132
133
/// The diff inspector, as the frame needs it.
134
pub struct DiffPane<'a> {
135
    /// The path of the file being shown.
136
    pub path: &'a str,
137
    /// Which file of how many.
138
    pub position: (usize, usize),
139
    pub mode: DiffMode,
140
    /// The already-rendered rows, from [`crate::diff::render`].
141
    pub rows: &'a [Line<'static>],
142
    /// Rows scrolled down from the top.
143
    pub scroll: usize,
144
}
145
146
/// A program running under a pseudoterminal, as the frame needs it.
147
pub struct PtyPane<'a> {
148
    pub command: &'a str,
149
    pub screen: &'a PtyScreen,
150
    /// Set once the program ended, with its exit code.
151
    pub exit: Option<u32>,
152
}
153
154
/// Which pane the middle of the frame is showing.
155
pub enum Middle<'a> {
156
    Transcript,
157
    Diff(DiffPane<'a>),
158
    Pty(PtyPane<'a>),
159
}
160
161
impl Middle<'_> {
162
    /// Whether this pane takes the keyboard, which is also whether the
163
    /// composer is drawn at all.
164
    fn takes_keys(&self) -> bool {
165
        !matches!(self, Middle::Transcript)
166
    }
167
}
168
107 169
/// Everything a frame needs. Borrowed, never owned.
108 170
pub struct ChromeView<'a> {
109 171
    pub title: &'a str,
110 172
    pub entries: &'a [Entry],
173
    /// Which pane fills the middle of the frame.
174
    pub middle: Middle<'a>,
111 175
    /// The composer's text, already soft-wrapped to [`composer_text_width`].
112 176
    pub composer_rows: &'a [&'a str],
113 177
    /// Caret position in the composer, as (row index, display column).
114 178
    pub composer_cursor: (usize, usize),
115
    /// The model the last grant named, or `None` before a turn has opened one.
179
    /// What Tab found, when it found more than one thing.
180
    pub completions: &'a [String],
181
    /// The model the last turn answered from, or `None` before a turn.
116 182
    pub model: Option<&'a str>,
183
    /// The lane this session runs on, as [`crate::runtime::Lane::label`] names
184
    /// it, with its tier when it has one.
185
    pub lane: &'a str,
186
    /// What the last turn spent, as the server reported it.
187
    pub usage: TurnUsage,
117 188
    /// True while a turn is streaming: the composer stops taking keys and says so.
118 189
    pub busy: bool,
119 190
    /// Flips on a timer to animate the streaming bullet.

@@ -156,26 +227,46 @@ impl BoxFrame {

156 227
    }
157 228
158 229
    pub fn render(&self, f: &mut Frame, area: Rect, view: &ChromeView) {
159
        // The composer grows with what is typed, up to a third of the screen,
160
        // and the transcript pays for it. Two rows of frame plus at least one
161
        // row of text.
162
        let composer_rows = view.composer_rows.len().clamp(1, composer_cap(area.height));
163
        let composer_height = composer_rows as u16 + 2;
164
165
        let chunks = Layout::default()
166
            .direction(Direction::Vertical)
167
            .constraints([
168
                Constraint::Length(3),
169
                Constraint::Min(3),
170
                Constraint::Length(composer_height),
171
                Constraint::Length(3),
172
            ])
173
            .split(area);
230
        let chunks = if view.middle.takes_keys() {
231
            // A pane that takes the keyboard gets the composer's rows: a
232
            // composer drawn under it would be a control that is not live.
233
            Layout::default()
234
                .direction(Direction::Vertical)
235
                .constraints([
236
                    Constraint::Length(3),
237
                    Constraint::Min(3),
238
                    Constraint::Length(3),
239
                ])
240
                .split(area)
241
        } else {
242
            // The composer grows with what is typed, up to a share of the
243
            // screen, and the transcript pays for it. Two rows of frame plus
244
            // at least one row of text.
245
            let composer_rows = view.composer_rows.len().clamp(1, composer_cap(area.height));
246
            let hints = u16::from(!view.completions.is_empty());
247
            Layout::default()
248
                .direction(Direction::Vertical)
249
                .constraints([
250
                    Constraint::Length(3),
251
                    Constraint::Min(3),
252
                    Constraint::Length(composer_rows as u16 + 2),
253
                    Constraint::Length(hints),
254
                    Constraint::Length(3),
255
                ])
256
                .split(area)
257
        };
174 258
175 259
        self.render_header(f, chunks[0]);
176
        render_transcript(f, chunks[1], view);
177
        render_composer(f, chunks[2], view);
178
        render_status(f, chunks[3], view);
260
        match &view.middle {
261
            Middle::Transcript => render_transcript(f, chunks[1], view),
262
            Middle::Diff(diff) => render_diff(f, chunks[1], diff),
263
            Middle::Pty(pty) => render_pty(f, chunks[1], pty),
264
        }
265
        if !view.middle.takes_keys() {
266
            render_composer(f, chunks[2], view);
267
            render_completions(f, chunks[3], view);
268
        }
269
        render_status(f, chunks[chunks.len() - 1], view);
179 270
180 271
        // `--no-color` is applied here rather than at each of the twenty-odd
181 272
        // places that name a colour, so a colour added later cannot escape it.

@@ -210,6 +301,22 @@ fn composer_cap(height: u16) -> usize {

210 301
    usize::from(height.saturating_sub(reserved)).clamp(1, 8)
211 302
}
212 303
304
/// The size, in columns and rows, of the pane a program under a pseudoterminal
305
/// is given inside a frame of `area`.
306
///
307
/// The child is told this and no more. It is the one number in the session
308
/// that has to be exactly right: a child that believes it has more columns
309
/// than the pane draws will wrap its own output in the wrong place.
310
///
311
/// The floor is [`crate::pty::MIN_SCREEN`]: below it there is no screen to
312
/// emulate, and the frame clips rather than reporting a size nothing can hold.
313
pub fn pty_viewport(area: Rect) -> (u16, u16) {
314
    (
315
        area.width.saturating_sub(2).max(MIN_PANE),
316
        area.height.saturating_sub(CHROME_ROWS).max(MIN_PANE),
317
    )
318
}
319
213 320
/// Break `text` into rows no wider than `width` columns, preferring to break
214 321
/// at a space. Hard newlines in the text are kept.
215 322
pub fn wrap(text: &str, width: usize) -> Vec<String> {

@@ -291,18 +398,28 @@ fn render_transcript(f: &mut Frame, area: Rect, view: &ChromeView) {

291 398
            _ => Style::default(),
292 399
        };
293 400
294
        let wrapped = if entry.text.is_empty() {
295
            vec!["…".to_string()]
401
        // The model writes markdown, so the model's turns are rendered as
402
        // markdown. Everything else is the text it says it is.
403
        let wrapped: Vec<Line<'static>> = if entry.text.is_empty() {
404
            vec![Line::from("…")]
405
        } else if entry.role.is_markdown() {
406
            crate::markdown::render(&entry.text, body)
296 407
        } else {
297 408
            wrap(&entry.text, body)
409
                .into_iter()
410
                .map(|row| Line::from(Span::styled(row, text_style)))
411
                .collect()
298 412
        };
413
299 414
        for (index, row) in wrapped.into_iter().enumerate() {
300 415
            let lead = if index == 0 {
301 416
                head.clone()
302 417
            } else {
303 418
                Span::raw(" ".repeat(GUTTER))
304 419
            };
305
            rows.push(Line::from(vec![lead, Span::styled(row, text_style)]));
420
            let mut spans = vec![lead];
421
            spans.extend(row.spans);
422
            rows.push(Line::from(spans));
306 423
        }
307 424
    }
308 425

@@ -315,6 +432,60 @@ fn render_transcript(f: &mut Frame, area: Rect, view: &ChromeView) {

315 432
    f.render_widget(Paragraph::new(window), inner);
316 433
}
317 434
435
/// The diff inspector: one file at a time, in the layout that was asked for.
436
fn render_diff(f: &mut Frame, area: Rect, diff: &DiffPane) {
437
    let (index, total) = diff.position;
438
    let title = format!(
439
        "Diff · {} · {} of {total} · {}",
440
        diff.path,
441
        index + 1,
442
        diff.mode.label()
443
    );
444
    let block = BoxFrame::pane(&title, Color::Cyan);
445
    let inner = block.inner(area);
446
    f.render_widget(block, area);
447
448
    let height = usize::from(inner.height);
449
    let window: Vec<Line> = diff
450
        .rows
451
        .iter()
452
        .skip(diff.scroll)
453
        .take(height)
454
        .cloned()
455
        .collect();
456
    f.render_widget(Paragraph::new(window), inner);
457
}
458
459
/// A program running under a pseudoterminal, drawn cell for cell.
460
fn render_pty(f: &mut Frame, area: Rect, pty: &PtyPane) {
461
    let title = match pty.exit {
462
        None => format!("Run · {}", pty.command),
463
        Some(0) => format!("Run · {} · finished", pty.command),
464
        Some(code) => format!("Run · {} · exited {code}", pty.command),
465
    };
466
    let color = match pty.exit {
467
        None => Color::Magenta,
468
        Some(0) => Color::Green,
469
        Some(_) => Color::Red,
470
    };
471
    let block = BoxFrame::pane(&title, color);
472
    let inner = block.inner(area);
473
    f.render_widget(block, area);
474
475
    pty.screen.render(inner, f.buffer_mut());
476
477
    // The child's cursor is the reader's cursor while the child has the keys.
478
    if pty.exit.is_none() {
479
        if let Some((col, row)) = pty.screen.cursor() {
480
            let x = inner.x + col;
481
            let y = inner.y + row;
482
            if x < inner.right() && y < inner.bottom() {
483
                f.set_cursor_position(Position::new(x, y));
484
            }
485
        }
486
    }
487
}
488
318 489
/// The composer: a `›` prompt, the text, and a caret the reader can see.
319 490
fn render_composer(f: &mut Frame, area: Rect, view: &ChromeView) {
320 491
    let (title, color) = if view.busy {

@@ -370,6 +541,44 @@ fn render_composer(f: &mut Frame, area: Rect, view: &ChromeView) {

370 541
    }
371 542
}
372 543
544
/// The row under the composer that lists what Tab found.
545
///
546
/// Tab inserts a candidate only when it is the only one. When several match it
547
/// extends as far as they agree and shows them here, which is the difference
548
/// between a completion and a guess.
549
fn render_completions(f: &mut Frame, area: Rect, view: &ChromeView) {
550
    if area.height == 0 || view.completions.is_empty() {
551
        return;
552
    }
553
    let budget = usize::from(area.width).saturating_sub(2);
554
    let mut row = String::new();
555
    let mut shown = 0usize;
556
    for candidate in view.completions {
557
        let addition = if row.is_empty() {
558
            candidate.clone()
559
        } else {
560
            format!("  {candidate}")
561
        };
562
        // The count of what is not shown is worth more than a truncated name.
563
        if row.width() + addition.width() > budget.saturating_sub(8) && shown > 0 {
564
            break;
565
        }
566
        row.push_str(&addition);
567
        shown += 1;
568
    }
569
    let hidden = view.completions.len() - shown;
570
    if hidden > 0 {
571
        row.push_str(&format!("  +{hidden} more"));
572
    }
573
    f.render_widget(
574
        Paragraph::new(Line::from(Span::styled(
575
            format!(" {row}"),
576
            Style::default().fg(Color::DarkGray),
577
        ))),
578
        area,
579
    );
580
}
581
373 582
/// How wide the composer's text is, given the whole frame's width.
374 583
///
375 584
/// The composer wraps to this before the view is built, so the caller and the

@@ -380,27 +589,39 @@ pub fn composer_text_width(frame_width: u16) -> usize {

380 589
        .max(4)
381 590
}
382 591
383
/// The keys the status bar offers, most useful first.
592
/// The keys the status bar offers, most useful first, for the pane showing.
384 593
///
385
/// Both of the ones the old bar advertised are gone, and neither could have
386
/// been made to work.
594
/// Every entry here is a key that is handled where it is offered, and the test
595
/// `every_key_the_status_bar_names_does_something` presses each of them.
387 596
///
388
/// `Tab: effort` toggled nothing: it appended the words `[Toggled reasoning
389
/// effort]` to the transcript, and `execute_turn` has no effort field to send
390
/// even if it had meant it. `Shift+Tab: lane` was never handled at all.
597
/// Two keys the bar once named are absent from all four lists. `Tab: effort`
598
/// toggled nothing: it appended the words `[Toggled reasoning effort]` to the
599
/// transcript, and `execute_turn` has no effort field to send even if it had
600
/// meant it. `Shift+Tab: lane` was never handled at all.
391 601
///
392 602
/// A lane control here is buildable — `POST /api/v1/threads` does take a
393
/// `model`, and `oa coder --lane` uses it — but it would have to open a new
394
/// thread to change one, since the grant a thread returns pins the model for
395
/// that thread's whole life. Mid-session cycling is therefore a session
396
/// decision, not a keystroke. Until that exists, the bar reports the model the
397
/// grant named, which is a fact rather than a request.
398
const HINTS: [&str; 4] = [
603
/// `model`, and `oa coder --lane` uses it — but changing one means opening a
604
/// new thread, since the grant a thread returns pins the model for that
605
/// thread's whole life. Mid-session cycling is a session decision, not a
606
/// keystroke. Until that exists the bar reports the model the grant named,
607
/// which is a fact rather than a request.
608
const HINTS_READY: [&str; 6] = [
399 609
    "Enter: send",
400 610
    "Esc: exit",
401 611
    "Alt+Enter: newline",
612
    "Tab: complete",
613
    "↑↓: history",
402 614
    "PgUp/PgDn: scroll",
403 615
];
616
const HINTS_BUSY: [&str; 2] = ["Esc: exit", "PgUp/PgDn: scroll"];
617
const HINTS_DIFF: [&str; 4] = [
618
    "Esc: close",
619
    "v: change view",
620
    "Tab: next file",
621
    "↑↓ PgUp/PgDn: scroll",
622
];
623
const HINTS_PTY: [&str; 1] = ["Ctrl+]: stop and go back"];
624
const HINTS_PTY_DONE: [&str; 1] = ["Enter: go back"];
404 625
405 626
/// Fit as many hints as the row holds, dropping them from the end.
406 627
///

@@ -420,6 +641,10 @@ fn hint_row(hints: &[&str], budget: usize) -> String {

420 641
}
421 642
422 643
/// The status bar. Every key it names is a key that works.
644
///
645
/// Its segments are dropped from the end when the window is too narrow, and
646
/// each is dropped whole. A bar that cut a segment in half would report
647
/// `Model: ox-alp`, which is a model that does not exist.
423 648
fn render_status(f: &mut Frame, area: Rect, view: &ChromeView) {
424 649
    let block = Block::default()
425 650
        .borders(Borders::ALL)

@@ -427,38 +652,71 @@ fn render_status(f: &mut Frame, area: Rect, view: &ChromeView) {

427 652
    let inner = block.inner(area);
428 653
    f.render_widget(block, area);
429 654
430
    let (state, state_color) = if view.busy {
431
        ("streaming", Color::Yellow)
432
    } else {
433
        ("ready", Color::Green)
655
    let (state, state_color) = match &view.middle {
656
        Middle::Pty(pty) if pty.exit.is_none() => ("running", Color::Magenta),
657
        _ if view.busy => ("streaming", Color::Yellow),
658
        _ => ("ready", Color::Green),
434 659
    };
435
    // While a turn streams the composer is on hold, so the keys that reach it
436
    // are not offered.
437
    let hints: &[&str] = if view.busy {
438
        &["Esc: exit", "PgUp/PgDn: scroll"]
439
    } else {
440
        &HINTS
660
    let hints: &[&str] = match &view.middle {
661
        Middle::Diff(_) => &HINTS_DIFF,
662
        Middle::Pty(pty) if pty.exit.is_none() => &HINTS_PTY,
663
        Middle::Pty(_) => &HINTS_PTY_DONE,
664
        // While a turn streams the composer is on hold, so the keys that reach
665
        // it are not offered.
666
        Middle::Transcript if view.busy => &HINTS_BUSY,
667
        Middle::Transcript => &HINTS_READY,
441 668
    };
442 669
443 670
    const SEPARATOR: &str = " │ ";
444 671
    let mut spans = vec![
445 672
        Span::styled(" Status: ", Style::default().fg(Color::DarkGray)),
446 673
        Span::styled(state, Style::default().fg(state_color)),
447
        Span::styled(SEPARATOR, BoxFrame::rule()),
674
    ];
675
676
    // In priority order. The model answers the question a reader asks most
677
    // often, so it outranks the lane it was asked for.
678
    let mut segments: Vec<Vec<Span<'static>>> = vec![vec![
448 679
        Span::styled("Model: ", Style::default().fg(Color::DarkGray)),
449 680
        Span::styled(
450
            view.model.unwrap_or("not yet granted"),
681
            view.model.unwrap_or("not yet granted").to_string(),
451 682
            Style::default().fg(if view.model.is_some() {
452 683
                Color::White
453 684
            } else {
454 685
                Color::DarkGray
455 686
            }),
456 687
        ),
457
    ];
458
    let used: usize = spans.iter().map(|span| span.content.width()).sum();
459
    let budget = usize::from(inner.width).saturating_sub(used + SEPARATOR.width());
688
    ]];
689
    segments.push(vec![
690
        Span::styled("Lane: ", Style::default().fg(Color::DarkGray)),
691
        Span::styled(view.lane.to_string(), Style::default().fg(Color::White)),
692
    ]);
693
    if view.usage.reported() {
694
        segments.push(vec![
695
            Span::styled("Tokens: ", Style::default().fg(Color::DarkGray)),
696
            Span::styled(
697
                format!(
698
                    "{}+{}={}",
699
                    view.usage.prompt_tokens, view.usage.completion_tokens, view.usage.total_tokens
700
                ),
701
                Style::default().fg(Color::White),
702
            ),
703
        ]);
704
    }
460 705
461
    let row = hint_row(hints, budget);
706
    let mut used: usize = spans.iter().map(|span| span.content.width()).sum();
707
    let room = usize::from(inner.width);
708
    for segment in segments {
709
        let cost: usize =
710
            SEPARATOR.width() + segment.iter().map(|s| s.content.width()).sum::<usize>();
711
        if used + cost > room {
712
            break;
713
        }
714
        used += cost;
715
        spans.push(Span::styled(SEPARATOR, BoxFrame::rule()));
716
        spans.extend(segment);
717
    }
718
719
    let row = hint_row(hints, room.saturating_sub(used + SEPARATOR.width()));
462 720
    if !row.is_empty() {
463 721
        spans.push(Span::styled(SEPARATOR, BoxFrame::rule()));
464 722
        spans.push(Span::styled(row, Style::default().fg(Color::DarkGray)));

@@ -491,6 +749,18 @@ mod tests {

491 749
        assert_eq!(wrap("a\nb", 10), vec!["a".to_string(), "b".to_string()]);
492 750
    }
493 751
752
    /// The pane a child is given is the frame less the chrome around it, and
753
    /// the child is told exactly that. A test on the arithmetic because a
754
    /// child told the wrong width wraps its own output in the wrong place.
755
    #[test]
756
    fn the_pty_viewport_is_the_pane_the_child_is_drawn_into() {
757
        assert_eq!(pty_viewport(Rect::new(0, 0, 80, 24)), (78, 16));
758
        // However small the window — including a terminal that reports no size
759
        // at all — the pane never goes below what can be emulated.
760
        assert_eq!(pty_viewport(Rect::new(0, 0, 1, 1)), (MIN_PANE, MIN_PANE));
761
        assert_eq!(pty_viewport(Rect::new(0, 0, 0, 0)), (MIN_PANE, MIN_PANE));
762
    }
763
494 764
    /// Draw the whole chrome into a buffer and report every foreground colour
495 765
    /// that is not the terminal's own.
496 766
    fn foregrounds(colour: bool) -> std::collections::BTreeSet<String> {

@@ -510,9 +780,13 @@ mod tests {

510 780
        let view = ChromeView {
511 781
            title: "openagents coder",
512 782
            entries: &entries,
783
            middle: Middle::Transcript,
513 784
            composer_rows: &rows,
514 785
            composer_cursor: (0, 6),
786
            completions: &[],
515 787
            model: Some("ox-alpha"),
788
            lane: "Coder (ox-alpha)",
789
            usage: TurnUsage::default(),
516 790
            busy: false,
517 791
            pulse: true,
518 792
            scrollback: 0,
crates/openagents-cli/tests/coder_tui_test.rs modified +1496 -20

@@ -12,7 +12,7 @@

12 12
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
13 13
use futures::Stream;
14 14
use openagents_cli::interactive::{run_loop, runtime_actor, CoderApp, Control, TurnEvent};
15
use openagents_cli::runtime::{CoderRuntimeSession, Lane};
15
use openagents_cli::runtime::{CoderRuntimeSession, Lane, TurnUsage};
16 16
use openagents_cli::tools::HarnessToolRegistry;
17 17
18 18
mod support;

@@ -44,6 +44,44 @@ fn screen(terminal: &Terminal<TestBackend>) -> String {

44 44
        .join("\n")
45 45
}
46 46
47
/// The first row inside the middle pane: below the header and the pane's own
48
/// top rule. A search that started above it would find a pane title, which is
49
/// drawn in the chrome's colours rather than the content's.
50
const PANE_TOP: u16 = 4;
51
52
/// Where a needle sits in the drawn frame, as (column, row), searching from
53
/// `from` downwards.
54
///
55
/// Counted in characters rather than bytes. The chrome is full of
56
/// box-drawing characters, which are three bytes each and one column each, so
57
/// a byte offset would point at the wrong cell on every row that has a rule
58
/// to the left of it.
59
fn position_from(terminal: &Terminal<TestBackend>, needle: &str, from: u16) -> (u16, u16) {
60
    let frame = screen(terminal);
61
    for (row, line) in frame.lines().enumerate().skip(from as usize) {
62
        if let Some(byte) = line.find(needle) {
63
            return (line[..byte].chars().count() as u16, row as u16);
64
        }
65
    }
66
    panic!("no row of the frame at or below {from} has {needle:?} on it:\n{frame}");
67
}
68
69
fn position_of(terminal: &Terminal<TestBackend>, needle: &str) -> (u16, u16) {
70
    position_from(terminal, needle, 0)
71
}
72
73
/// The cell the first character of `needle` is drawn in.
74
fn cell_of(terminal: &Terminal<TestBackend>, needle: &str) -> ratatui::buffer::Cell {
75
    let (column, row) = position_of(terminal, needle);
76
    terminal.backend().buffer()[(column, row)].clone()
77
}
78
79
/// The same, searching only inside the middle pane.
80
fn cell_in_the_pane(terminal: &Terminal<TestBackend>, needle: &str) -> ratatui::buffer::Cell {
81
    let (column, row) = position_from(terminal, needle, PANE_TOP);
82
    terminal.backend().buffer()[(column, row)].clone()
83
}
84
47 85
fn key(code: KeyCode) -> KeyEvent {
48 86
    KeyEvent::new(code, KeyModifiers::NONE)
49 87
}

@@ -54,7 +92,7 @@ fn app() -> (

54 92
    UnboundedReceiver<Control>,
55 93
) {
56 94
    let (tx, rx) = unbounded_channel();
57
    (CoderApp::new("openagents coder"), tx, rx)
95
    (CoderApp::new("openagents coder", &Lane::OxAlpha), tx, rx)
58 96
}
59 97
60 98
fn type_str(app: &mut CoderApp, control: &UnboundedSender<Control>, text: &str) {

@@ -247,9 +285,15 @@ fn a_failed_turn_lands_on_the_transcript_and_the_session_stays_open() {

247 285
248 286
// ------------------------------------------------------------- the keybinds
249 287
288
/// Every key the transcript's status bar names has to do something.
289
///
290
/// The bar is the only place a reader learns what the session can do, so a
291
/// label with nothing behind it is the exact failure this issue was reopened
292
/// for. Each hint here is pressed, and each press is asserted.
250 293
#[test]
251 294
fn every_key_the_status_bar_names_does_something() {
252
    const WIDE: u16 = 120;
295
    // Wide enough that the bar drops nothing; the narrow case is its own test.
296
    const WIDE: u16 = 200;
253 297
    let (mut app, control, mut rx) = app();
254 298
    let mut term = terminal_of(WIDE, HEIGHT);
255 299
    app.draw(&mut term).unwrap();

@@ -257,10 +301,19 @@ fn every_key_the_status_bar_names_does_something() {

257 301
258 302
    // Whatever the bar claims, claim it here too, so a new label without a
259 303
    // key behind it fails this test.
260
    assert!(frame.contains("Enter: send"), "{frame}");
261
    assert!(frame.contains("Alt+Enter: newline"), "{frame}");
262
    assert!(frame.contains("PgUp/PgDn: scroll"), "{frame}");
263
    assert!(frame.contains("Esc: exit"), "{frame}");
304
    for hint in [
305
        "Enter: send",
306
        "Esc: exit",
307
        "Alt+Enter: newline",
308
        "Tab: complete",
309
        "\u{2191}\u{2193}: history",
310
        "PgUp/PgDn: scroll",
311
    ] {
312
        assert!(
313
            frame.contains(hint),
314
            "the bar does not offer {hint}:\n{frame}"
315
        );
316
    }
264 317
265 318
    // Neither of the keys the old bar advertised is here. `Tab: effort` had
266 319
    // nothing behind it — `execute_turn` sends no effort field. `Shift+Tab:

@@ -269,12 +322,46 @@ fn every_key_the_status_bar_names_does_something() {

269 322
    assert!(!frame.contains("Tab: effort"), "{frame}");
270 323
    assert!(!frame.contains("Shift+Tab"), "{frame}");
271 324
325
    // Alt+Enter opens a second row.
326
    type_str(&mut app, &control, "one");
327
    app.on_key(
328
        &KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
329
        WIDE,
330
        &control,
331
    );
332
    type_str(&mut app, &control, "two");
333
    app.draw(&mut term).unwrap();
334
    assert!(screen(&term).contains("  two"), "{}", screen(&term));
335
272 336
    // Enter sends.
273
    type_str(&mut app, &control, "x");
274 337
    app.on_key(&key(KeyCode::Enter), WIDE, &control);
275
    assert!(matches!(rx.try_recv(), Ok(Control::Prompt(_))));
338
    assert!(matches!(rx.try_recv(), Ok(Control::Prompt(p)) if p == "one\ntwo"));
276 339
    app.on_turn_event(TurnEvent::Done("ok".to_string()));
277 340
341
    // Tab completes: `/he` is only `/help`.
342
    type_str(&mut app, &control, "/he");
343
    app.on_key(&key(KeyCode::Tab), WIDE, &control);
344
    app.draw(&mut term).unwrap();
345
    assert!(
346
        screen(&term).contains("\u{203a} /help "),
347
        "{}",
348
        screen(&term)
349
    );
350
351
    // Up recalls what was sent.
352
    for _ in 0..40 {
353
        app.on_key(&key(KeyCode::Backspace), WIDE, &control);
354
    }
355
    app.on_key(&key(KeyCode::Up), WIDE, &control);
356
    app.draw(&mut term).unwrap();
357
    assert!(screen(&term).contains("\u{203a} one"), "{}", screen(&term));
358
359
    // PgUp scrolls, and PgDn comes back. Proved on its own transcript test;
360
    // here it is enough that the key is taken rather than typed.
361
    app.on_key(&key(KeyCode::PageUp), WIDE, &control);
362
    app.draw(&mut term).unwrap();
363
    assert!(!screen(&term).contains("PageUp"), "{}", screen(&term));
364
278 365
    // Esc exits.
279 366
    app.on_key(&key(KeyCode::Esc), WIDE, &control);
280 367
    assert!(app.should_exit());

@@ -293,9 +380,10 @@ fn shift_tab_is_not_bound() {

293 380
    );
294 381
}
295 382
296
/// The bar names the model the grant chose, and says so honestly before one.
383
/// The bar names the model the last turn answered from, and says so
384
/// honestly before there has been one.
297 385
#[test]
298
fn the_model_shown_is_the_one_the_grant_named() {
386
fn the_model_shown_is_the_one_the_turn_answered_from() {
299 387
    let (mut app, control, _rx) = app();
300 388
    let mut term = terminal_of(120, HEIGHT);
301 389
    app.draw(&mut term).unwrap();

@@ -371,22 +459,33 @@ fn status_row(terminal: &Terminal<TestBackend>) -> String {

371 459
372 460
#[test]
373 461
fn a_narrow_window_drops_hints_rather_than_showing_half_of_one() {
374
    let (app, _control, _rx) = app();
462
    let (mut app, _control, _rx) = app();
375 463
376 464
    // Wide enough for two whole hints and no more.
377
    let mut term = terminal_of(74, HEIGHT);
465
    let mut term = terminal_of(100, HEIGHT);
378 466
    app.draw(&mut term).unwrap();
379 467
    let row = status_row(&term);
380 468
    assert!(row.contains("Enter: send \u{b7} Esc: exit"), "{row}");
381
    assert!(!row.contains("PgU"), "a hint was cut in half: {row}");
469
    assert!(!row.contains("Alt+Ent"), "a hint was cut in half: {row}");
470
471
    // Too narrow for even the first hint: the segments stay, the hints go.
472
    let mut term = terminal_of(74, HEIGHT);
473
    app.draw(&mut term).unwrap();
474
    let row = status_row(&term);
475
    assert!(row.contains("Lane: Coder (ox-alpha)"), "{row}");
476
    assert!(!row.contains("Ent"), "a hint was cut in half: {row}");
382 477
383
    // Too narrow for even the first: the status and the lane stay, the hints go.
478
    // Narrower still: the lowest-priority segment goes too, whole.
384 479
    let mut term = terminal_of(46, HEIGHT);
385 480
    app.draw(&mut term).unwrap();
386 481
    let row = status_row(&term);
387 482
    assert!(row.contains("Status: ready"), "{row}");
388 483
    assert!(row.contains("Model: not yet granted"), "{row}");
389 484
    assert!(!row.contains("Ent"), "a hint was cut in half: {row}");
485
    assert!(
486
        !row.contains("Lane"),
487
        "a segment was kept that could not fit: {row}"
488
    );
390 489
    for line in screen(&term).lines() {
391 490
        assert_eq!(
392 491
            line.chars().count(),

@@ -411,7 +510,7 @@ fn ctrl_c_exits() {

411 510
#[test]
412 511
fn the_welcome_text_promises_only_what_the_screen_does() {
413 512
    let mut term = terminal();
414
    let (app, _control, _rx) = app();
513
    let (mut app, _control, _rx) = app();
415 514
    app.draw(&mut term).unwrap();
416 515
    let frame = screen(&term);
417 516
    assert!(frame.contains("Type a prompt"), "{frame}");

@@ -454,7 +553,7 @@ fn send_keys(tx: &UnboundedSender<Event>, text: &str) {

454 553
#[tokio::test]
455 554
async fn end_to_end_over_the_loop_with_a_stub_runtime() {
456 555
    let mut term = terminal();
457
    let mut app = CoderApp::new("openagents coder");
556
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
458 557
    let (keys_tx, keys_rx) = unbounded_channel();
459 558
    let (control_tx, mut control_rx) = unbounded_channel::<Control>();
460 559
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();

@@ -525,7 +624,7 @@ async fn end_to_end_over_real_http_shows_a_chunk_before_the_turn_finishes() {

525 624
    tokio::spawn(runtime_actor(session, control_rx, turn_tx.clone()));
526 625
527 626
    let mut term = terminal();
528
    let mut app = CoderApp::new("openagents coder");
627
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
529 628
    let (keys_tx, keys_rx) = unbounded_channel();
530 629
531 630
    send_keys(&keys_tx, "read the repo");

@@ -586,7 +685,7 @@ async fn end_to_end_over_real_http_streams_a_whole_reply_onto_the_transcript() {

586 685
    tokio::spawn(runtime_actor(session, control_rx, turn_tx.clone()));
587 686
588 687
    let mut term = terminal();
589
    let mut app = CoderApp::new("openagents coder");
688
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
590 689
    let (keys_tx, keys_rx) = unbounded_channel();
591 690
592 691
    send_keys(&keys_tx, "what changed");

@@ -643,7 +742,7 @@ async fn a_refused_turn_says_so_on_the_transcript() {

643 742
    tokio::spawn(runtime_actor(session, control_rx, turn_tx.clone()));
644 743
645 744
    let mut term = terminal_of(100, HEIGHT);
646
    let mut app = CoderApp::new("openagents coder");
745
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
647 746
    let (keys_tx, keys_rx) = unbounded_channel();
648 747
    send_keys(&keys_tx, "hello");
649 748
    let _ = keys_tx.send(Event::Key(key(KeyCode::Enter)));

@@ -676,3 +775,1380 @@ async fn a_refused_turn_says_so_on_the_transcript() {

676 775
        "the composer stayed on hold:\n{frame}"
677 776
    );
678 777
}
778
779
// ------------------------------------------------------- markdown and code
780
781
/// A model writes markdown. Before this the transcript printed the marks.
782
#[test]
783
fn a_reply_in_markdown_is_rendered_rather_than_printed_with_its_marks() {
784
    let (mut app, control, _rx) = app();
785
    let mut term = terminal();
786
    type_str(&mut app, &control, "explain");
787
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
788
    app.on_turn_event(TurnEvent::Chunk(
789
        "Run `mix precommit` and read **the output**.".to_string(),
790
    ));
791
    app.on_turn_event(TurnEvent::Done(String::new()));
792
    app.draw(&mut term).unwrap();
793
794
    let frame = screen(&term);
795
    assert!(
796
        frame.contains("Run mix precommit and read the output."),
797
        "the markdown was not rendered:\n{frame}"
798
    );
799
    assert!(
800
        !frame.contains("**the output**"),
801
        "the marks are still on the screen:\n{frame}"
802
    );
803
    assert!(!frame.contains("`mix"), "{frame}");
804
}
805
806
/// A heading is drawn bold and cyan, which is a claim about the frame's cells
807
/// rather than about its text.
808
#[test]
809
fn a_heading_in_a_reply_is_drawn_bold() {
810
    use ratatui::style::Modifier;
811
    let (mut app, control, _rx) = app();
812
    let mut term = terminal();
813
    type_str(&mut app, &control, "go");
814
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
815
    app.on_turn_event(TurnEvent::Chunk("## Findings".to_string()));
816
    app.on_turn_event(TurnEvent::Done(String::new()));
817
    app.draw(&mut term).unwrap();
818
819
    let frame = screen(&term);
820
    assert!(frame.contains("Findings"), "{frame}");
821
    assert!(!frame.contains("## Findings"), "{frame}");
822
823
    assert!(
824
        cell_of(&term, "Findings").modifier.contains(Modifier::BOLD),
825
        "the heading was not drawn bold:\n{frame}"
826
    );
827
}
828
829
/// A fenced block gets its rail, and the code inside it is highlighted.
830
#[test]
831
fn a_fenced_code_block_is_railed_and_highlighted_in_the_transcript() {
832
    use ratatui::style::Color;
833
    let (mut app, control, _rx) = app();
834
    let mut term = terminal();
835
    type_str(&mut app, &control, "show me");
836
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
837
    app.on_turn_event(TurnEvent::Chunk(
838
        "```rust\nlet answer = 42;\n```".to_string(),
839
    ));
840
    app.on_turn_event(TurnEvent::Done(String::new()));
841
    app.draw(&mut term).unwrap();
842
843
    let frame = screen(&term);
844
    assert!(frame.contains("\u{256d}\u{2500} rust"), "{frame}");
845
    assert!(frame.contains("\u{2502} let answer = 42;"), "{frame}");
846
    assert!(frame.contains("\u{2570}\u{2500}"), "{frame}");
847
848
    // `let` is a keyword and is coloured as one.
849
    assert_eq!(
850
        cell_of(&term, "let answer").fg,
851
        Color::Magenta,
852
        "the keyword was not highlighted:\n{frame}"
853
    );
854
}
855
856
/// The half-written state every chunk but the last is in.
857
#[test]
858
fn a_code_fence_that_is_still_arriving_is_already_drawn_as_code() {
859
    let (mut app, control, _rx) = app();
860
    let mut term = terminal();
861
    type_str(&mut app, &control, "write it");
862
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
863
    app.on_turn_event(TurnEvent::Chunk("```rust\nfn main() {".to_string()));
864
    app.draw(&mut term).unwrap();
865
    assert!(
866
        screen(&term).contains("\u{2502} fn main() {"),
867
        "an unclosed fence held its contents back:\n{}",
868
        screen(&term)
869
    );
870
}
871
872
/// A notice this program wrote is not markdown and is not treated as any.
873
#[test]
874
fn a_notice_is_shown_as_the_text_it_is() {
875
    let (mut app, control, _rx) = app();
876
    let mut term = terminal();
877
    app.on_turn_event(TurnEvent::Notice("Wrote src/some_file_name.rs".to_string()));
878
    app.draw(&mut term).unwrap();
879
    let _ = control;
880
    assert!(
881
        screen(&term).contains("Wrote src/some_file_name.rs"),
882
        "an underscore in a path was eaten as emphasis:\n{}",
883
        screen(&term)
884
    );
885
}
886
887
// ------------------------------------------------------------- status bar
888
889
#[test]
890
fn the_bar_names_the_lane_and_its_tier() {
891
    let mut app = CoderApp::new("openagents coder", &Lane::Flash);
892
    let mut term = terminal_of(120, HEIGHT);
893
    app.draw(&mut term).unwrap();
894
    assert!(
895
        status_row(&term).contains("Lane: Coder Flash (flash)"),
896
        "{}",
897
        status_row(&term)
898
    );
899
}
900
901
/// A lane that belongs to no tier is not given an invented one.
902
#[test]
903
fn a_lane_with_no_tier_is_named_without_one() {
904
    let mut app = CoderApp::new("openagents coder", &Lane::Named("some-model".to_string()));
905
    let mut term = terminal_of(120, HEIGHT);
906
    app.draw(&mut term).unwrap();
907
    let row = status_row(&term);
908
    assert!(row.contains("Lane: Coder (some-model)"), "{row}");
909
    assert!(!row.contains("(auto)"), "{row}");
910
}
911
912
/// Nothing is reported until the server has reported something. A zero would
913
/// read as "this turn was free", which is a different claim from "unknown".
914
#[test]
915
fn tokens_are_shown_only_once_the_server_has_said_what_a_turn_cost() {
916
    let (mut app, _control, _rx) = app();
917
    let mut term = terminal_of(140, HEIGHT);
918
    app.draw(&mut term).unwrap();
919
    assert!(
920
        !status_row(&term).contains("Tokens"),
921
        "{}",
922
        status_row(&term)
923
    );
924
925
    app.on_turn_event(TurnEvent::Usage(TurnUsage {
926
        prompt_tokens: 128,
927
        completion_tokens: 64,
928
        total_tokens: 192,
929
    }));
930
    app.draw(&mut term).unwrap();
931
    assert!(
932
        status_row(&term).contains("Tokens: 128+64=192"),
933
        "{}",
934
        status_row(&term)
935
    );
936
}
937
938
// ------------------------------------------------------------ the history
939
940
#[test]
941
fn up_and_down_walk_the_prompts_that_were_sent() {
942
    let (mut app, control, _rx) = app();
943
    let mut term = terminal();
944
945
    type_str(&mut app, &control, "the first question");
946
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
947
    app.on_turn_event(TurnEvent::Done("ok".to_string()));
948
    type_str(&mut app, &control, "the second question");
949
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
950
    app.on_turn_event(TurnEvent::Done("ok".to_string()));
951
952
    app.on_key(&key(KeyCode::Up), WIDTH, &control);
953
    app.draw(&mut term).unwrap();
954
    assert!(
955
        screen(&term).contains("\u{203a} the second question"),
956
        "Up did not recall the last prompt:\n{}",
957
        screen(&term)
958
    );
959
960
    app.on_key(&key(KeyCode::Up), WIDTH, &control);
961
    app.draw(&mut term).unwrap();
962
    assert!(
963
        screen(&term).contains("\u{203a} the first question"),
964
        "{}",
965
        screen(&term)
966
    );
967
968
    app.on_key(&key(KeyCode::Down), WIDTH, &control);
969
    app.draw(&mut term).unwrap();
970
    assert!(
971
        screen(&term).contains("\u{203a} the second question"),
972
        "{}",
973
        screen(&term)
974
    );
975
}
976
977
/// A half-typed line is not lost by looking back at the history.
978
#[test]
979
fn walking_back_and_forward_returns_the_draft() {
980
    let (mut app, control, _rx) = app();
981
    let mut term = terminal();
982
    type_str(&mut app, &control, "sent");
983
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
984
    app.on_turn_event(TurnEvent::Done("ok".to_string()));
985
986
    type_str(&mut app, &control, "half typed");
987
    app.on_key(&key(KeyCode::Up), WIDTH, &control);
988
    app.on_key(&key(KeyCode::Down), WIDTH, &control);
989
    app.draw(&mut term).unwrap();
990
    assert!(
991
        screen(&term).contains("\u{203a} half typed"),
992
        "the draft was lost:\n{}",
993
        screen(&term)
994
    );
995
}
996
997
/// A recalled prompt can be edited and sent again, which is the whole point.
998
#[test]
999
fn a_recalled_prompt_can_be_changed_and_sent_again() {
1000
    let (mut app, control, mut rx) = app();
1001
    type_str(&mut app, &control, "list the issues");
1002
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1003
    let _ = rx.try_recv();
1004
    app.on_turn_event(TurnEvent::Done("ok".to_string()));
1005
1006
    app.on_key(&key(KeyCode::Up), WIDTH, &control);
1007
    type_str(&mut app, &control, " again");
1008
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1009
    assert!(matches!(rx.try_recv(), Ok(Control::Prompt(p)) if p == "list the issues again"));
1010
}
1011
1012
/// Scrolling is PgUp and PgDn, which is what the bar names. Up and Down are
1013
/// the history, so a reader looking back at what they typed does not have the
1014
/// transcript slide under them.
1015
#[test]
1016
fn up_does_not_scroll_the_transcript() {
1017
    let (mut app, control, _rx) = app();
1018
    let mut term = terminal();
1019
    type_str(&mut app, &control, "a question");
1020
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1021
    app.on_turn_event(TurnEvent::Chunk(
1022
        (1..=30)
1023
            .map(|n| format!("line {n}"))
1024
            .collect::<Vec<_>>()
1025
            .join("\n"),
1026
    ));
1027
    app.on_turn_event(TurnEvent::Done(String::new()));
1028
1029
    for _ in 0..10 {
1030
        app.on_key(&key(KeyCode::Up), WIDTH, &control);
1031
    }
1032
    app.draw(&mut term).unwrap();
1033
    assert!(
1034
        screen(&term).contains("line 30"),
1035
        "Up scrolled the transcript:\n{}",
1036
        screen(&term)
1037
    );
1038
}
1039
1040
// --------------------------------------------------------- the completions
1041
1042
fn scratch_directory() -> tempfile::TempDir {
1043
    let dir = tempfile::tempdir().expect("a temporary directory");
1044
    std::fs::create_dir(dir.path().join("crates")).expect("crates/");
1045
    std::fs::write(dir.path().join("README.md"), "").expect("README.md");
1046
    dir
1047
}
1048
1049
#[test]
1050
fn tab_completes_the_only_command_that_matches() {
1051
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1052
    let dir = scratch_directory();
1053
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha)
1054
        .with_working_directory(dir.path().to_path_buf());
1055
    let mut term = terminal();
1056
1057
    type_str(&mut app, &tx, "/exp");
1058
    app.on_key(&key(KeyCode::Tab), WIDTH, &tx);
1059
    app.draw(&mut term).unwrap();
1060
    assert!(
1061
        screen(&term).contains("\u{203a} /export "),
1062
        "{}",
1063
        screen(&term)
1064
    );
1065
}
1066
1067
#[test]
1068
fn tab_lists_the_candidates_rather_than_choosing_one() {
1069
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1070
    let dir = scratch_directory();
1071
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha)
1072
        .with_working_directory(dir.path().to_path_buf());
1073
    let mut term = terminal();
1074
1075
    type_str(&mut app, &tx, "/");
1076
    app.on_key(&key(KeyCode::Tab), WIDTH, &tx);
1077
    app.draw(&mut term).unwrap();
1078
1079
    let frame = screen(&term);
1080
    assert!(
1081
        frame.contains("clear  diff  export  help  run"),
1082
        "the candidates were not listed:\n{frame}"
1083
    );
1084
    let composer = frame
1085
        .lines()
1086
        .find(|line| line.contains('\u{203a}'))
1087
        .expect("a composer row");
1088
    assert_eq!(
1089
        composer.trim_matches('\u{2502}').trim_end(),
1090
        "\u{203a} /",
1091
        "Tab chose a command when five matched:\n{frame}"
1092
    );
1093
}
1094
1095
#[test]
1096
fn tab_completes_a_path_in_the_working_directory() {
1097
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1098
    let dir = scratch_directory();
1099
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha)
1100
        .with_working_directory(dir.path().to_path_buf());
1101
    let mut term = terminal();
1102
1103
    type_str(&mut app, &tx, "look at REA");
1104
    app.on_key(&key(KeyCode::Tab), WIDTH, &tx);
1105
    app.draw(&mut term).unwrap();
1106
    assert!(
1107
        screen(&term).contains("look at README.md"),
1108
        "{}",
1109
        screen(&term)
1110
    );
1111
}
1112
1113
/// The list is transient: the next keystroke narrows the set, so leaving the
1114
/// old candidates up would be showing the answer to the previous question.
1115
#[test]
1116
fn the_candidate_list_goes_away_on_the_next_keystroke() {
1117
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1118
    let dir = scratch_directory();
1119
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha)
1120
        .with_working_directory(dir.path().to_path_buf());
1121
    let mut term = terminal();
1122
1123
    type_str(&mut app, &tx, "/");
1124
    app.on_key(&key(KeyCode::Tab), WIDTH, &tx);
1125
    app.draw(&mut term).unwrap();
1126
    assert!(screen(&term).contains("export"), "{}", screen(&term));
1127
1128
    type_str(&mut app, &tx, "c");
1129
    app.draw(&mut term).unwrap();
1130
    assert!(
1131
        !screen(&term).contains("clear  diff  export"),
1132
        "the stale candidate list stayed up:\n{}",
1133
        screen(&term)
1134
    );
1135
}
1136
1137
// ------------------------------------------------------------- the commands
1138
1139
#[test]
1140
fn an_unknown_command_is_refused_rather_than_sent_to_the_model() {
1141
    let (mut app, control, mut rx) = app();
1142
    let mut term = terminal();
1143
    type_str(&mut app, &control, "/difff");
1144
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1145
    app.draw(&mut term).unwrap();
1146
1147
    assert!(
1148
        rx.try_recv().is_err(),
1149
        "a mistyped command was sent to the model as a prompt"
1150
    );
1151
    assert!(
1152
        screen(&term).contains("There is no `/difff`"),
1153
        "{}",
1154
        screen(&term)
1155
    );
1156
}
1157
1158
#[test]
1159
fn slash_help_lists_every_command_the_session_handles() {
1160
    let (mut app, control, _rx) = app();
1161
    let mut term = terminal_of(100, 40);
1162
    type_str(&mut app, &control, "/help");
1163
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1164
    app.draw(&mut term).unwrap();
1165
1166
    let frame = screen(&term);
1167
    for (name, _) in openagents_cli::interactive::COMMANDS {
1168
        assert!(
1169
            frame.contains(&format!("/{name}")),
1170
            "`/{name}` is handled and not listed:\n{frame}"
1171
        );
1172
    }
1173
}
1174
1175
#[test]
1176
fn slash_clear_empties_the_transcript() {
1177
    let (mut app, control, _rx) = app();
1178
    let mut term = terminal();
1179
    type_str(&mut app, &control, "a question");
1180
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1181
    app.on_turn_event(TurnEvent::Done("an answer".to_string()));
1182
    app.draw(&mut term).unwrap();
1183
    assert!(screen(&term).contains("an answer"));
1184
1185
    type_str(&mut app, &control, "/clear");
1186
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1187
    app.draw(&mut term).unwrap();
1188
    let frame = screen(&term);
1189
    assert!(!frame.contains("an answer"), "{frame}");
1190
    assert!(!frame.contains("a question"), "{frame}");
1191
}
1192
1193
#[test]
1194
fn slash_export_writes_the_transcript_where_it_was_told_to() {
1195
    let dir = tempfile::tempdir().expect("a temporary directory");
1196
    let path = dir.path().join("session.txt");
1197
    let (mut app, control, _rx) = app();
1198
    let mut term = terminal();
1199
1200
    type_str(&mut app, &control, "what changed");
1201
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1202
    app.on_turn_event(TurnEvent::Done("Two files.".to_string()));
1203
    type_str(&mut app, &control, &format!("/export {}", path.display()));
1204
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1205
    app.draw(&mut term).unwrap();
1206
1207
    let written = std::fs::read_to_string(&path).expect("the transcript file");
1208
    assert!(written.contains("[you] what changed"), "{written}");
1209
    assert!(written.contains("[coder] Two files."), "{written}");
1210
    assert!(
1211
        screen(&term).contains("Transcript written to"),
1212
        "{}",
1213
        screen(&term)
1214
    );
1215
}
1216
1217
#[test]
1218
fn slash_export_without_a_path_says_so_instead_of_guessing_one() {
1219
    let (mut app, control, _rx) = app();
1220
    let mut term = terminal();
1221
    type_str(&mut app, &control, "/export");
1222
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1223
    app.draw(&mut term).unwrap();
1224
    assert!(screen(&term).contains("needs a path"), "{}", screen(&term));
1225
}
1226
1227
// --------------------------------------------------------- the diff inspector
1228
1229
const TWO_FILE_DIFF: &str = "\
1230
diff --git a/lib/thing.ex b/lib/thing.ex
1231
--- a/lib/thing.ex
1232
+++ b/lib/thing.ex
1233
@@ -1,3 +1,3 @@
1234
 defmodule Thing do
1235
-  def run, do: :old
1236
+  def run, do: :new
1237
 end
1238
diff --git a/README.md b/README.md
1239
--- a/README.md
1240
+++ b/README.md
1241
@@ -1,2 +1,2 @@
1242
 # Title
1243
-first line
1244
+second line
1245
";
1246
1247
fn with_a_diff() -> (
1248
    CoderApp,
1249
    UnboundedSender<Control>,
1250
    UnboundedReceiver<Control>,
1251
) {
1252
    let (mut app, tx, rx) = app();
1253
    app.on_turn_event(TurnEvent::Diff(openagents_cli::diff::parse_unified(
1254
        TWO_FILE_DIFF,
1255
    )));
1256
    (app, tx, rx)
1257
}
1258
1259
#[test]
1260
fn the_inspector_opens_on_a_diff_and_shows_the_change_unified() {
1261
    let (mut app, _control, _rx) = with_a_diff();
1262
    let mut term = terminal_of(90, HEIGHT);
1263
    app.draw(&mut term).unwrap();
1264
1265
    let frame = screen(&term);
1266
    assert!(app.inspecting(), "the inspector did not open");
1267
    assert!(frame.contains("Diff \u{b7} lib/thing.ex"), "{frame}");
1268
    assert!(frame.contains("1 of 2"), "{frame}");
1269
    assert!(frame.contains("unified"), "{frame}");
1270
    assert!(frame.contains("@@ -1,3 +1,3 @@"), "{frame}");
1271
    assert!(frame.contains("\u{2212}   def run, do: :old"), "{frame}");
1272
    assert!(frame.contains("+   def run, do: :new"), "{frame}");
1273
    // The composer is not drawn: it would be a control that is not live.
1274
    assert!(
1275
        !frame.contains("Message"),
1276
        "the composer was drawn under a pane that takes the keyboard:\n{frame}"
1277
    );
1278
}
1279
1280
/// Additions are green and removals red, asserted on the cells.
1281
#[test]
1282
fn the_two_sides_of_a_change_are_coloured_apart() {
1283
    use ratatui::style::Color;
1284
    let (mut app, _control, _rx) = with_a_diff();
1285
    let mut term = terminal_of(90, HEIGHT);
1286
    app.draw(&mut term).unwrap();
1287
1288
    assert_eq!(cell_of(&term, "def run, do: :old").fg, Color::Red);
1289
    assert_eq!(cell_of(&term, "def run, do: :new").fg, Color::Green);
1290
}
1291
1292
#[test]
1293
fn v_switches_between_the_unified_and_side_by_side_views() {
1294
    let (mut app, control, _rx) = with_a_diff();
1295
    let mut term = terminal_of(90, HEIGHT);
1296
    app.draw(&mut term).unwrap();
1297
1298
    app.on_key(&key(KeyCode::Char('v')), 90, &control);
1299
    app.draw(&mut term).unwrap();
1300
    let frame = screen(&term);
1301
    assert!(frame.contains("side by side"), "{frame}");
1302
    // Both texts on one row is what side by side means.
1303
    assert!(
1304
        frame
1305
            .lines()
1306
            .any(|line| line.contains(":old") && line.contains(":new")),
1307
        "the two sides are not opposite each other:\n{frame}"
1308
    );
1309
1310
    app.on_key(&key(KeyCode::Char('v')), 90, &control);
1311
    app.draw(&mut term).unwrap();
1312
    let frame = screen(&term);
1313
    assert!(frame.contains("unified"), "{frame}");
1314
    assert!(
1315
        !frame
1316
            .lines()
1317
            .any(|line| line.contains(":old") && line.contains(":new")),
1318
        "{frame}"
1319
    );
1320
}
1321
1322
#[test]
1323
fn tab_moves_to_the_next_file_and_wraps_round() {
1324
    let (mut app, control, _rx) = with_a_diff();
1325
    let mut term = terminal_of(90, HEIGHT);
1326
    app.draw(&mut term).unwrap();
1327
1328
    app.on_key(&key(KeyCode::Tab), 90, &control);
1329
    app.draw(&mut term).unwrap();
1330
    let frame = screen(&term);
1331
    assert!(frame.contains("Diff \u{b7} README.md"), "{frame}");
1332
    assert!(frame.contains("2 of 2"), "{frame}");
1333
    assert!(frame.contains("second line"), "{frame}");
1334
1335
    app.on_key(&key(KeyCode::Tab), 90, &control);
1336
    app.draw(&mut term).unwrap();
1337
    assert!(screen(&term).contains("1 of 2"), "{}", screen(&term));
1338
}
1339
1340
#[test]
1341
fn esc_closes_the_inspector_and_leaves_the_session_open() {
1342
    let (mut app, control, _rx) = with_a_diff();
1343
    let mut term = terminal_of(90, HEIGHT);
1344
    app.draw(&mut term).unwrap();
1345
1346
    app.on_key(&key(KeyCode::Esc), 90, &control);
1347
    app.draw(&mut term).unwrap();
1348
    assert!(!app.inspecting());
1349
    assert!(!app.should_exit(), "Esc in the inspector ended the session");
1350
    assert!(screen(&term).contains("Message"), "{}", screen(&term));
1351
}
1352
1353
/// While the inspector is up it has the keyboard, so a key that would have
1354
/// been typed does not land in a composer nobody can see.
1355
#[test]
1356
fn the_inspector_takes_the_keyboard_from_the_composer() {
1357
    let (mut app, control, _rx) = with_a_diff();
1358
    let mut term = terminal_of(90, HEIGHT);
1359
    type_str(&mut app, &control, "hello");
1360
    app.on_key(&key(KeyCode::Esc), 90, &control);
1361
    app.draw(&mut term).unwrap();
1362
    assert!(
1363
        !screen(&term).contains("\u{203a} hello"),
1364
        "keys pressed over the inspector reached the composer:\n{}",
1365
        screen(&term)
1366
    );
1367
}
1368
1369
#[test]
1370
fn a_diff_with_no_changes_says_so_rather_than_opening_an_empty_pane() {
1371
    let (mut app, _control, _rx) = app();
1372
    let mut term = terminal();
1373
    app.on_turn_event(TurnEvent::Diff(Vec::new()));
1374
    app.draw(&mut term).unwrap();
1375
    assert!(!app.inspecting());
1376
    assert!(
1377
        screen(&term).contains("Nothing has changed"),
1378
        "{}",
1379
        screen(&term)
1380
    );
1381
}
1382
1383
/// Scrolling stops at the last row rather than running off into a blank pane.
1384
#[test]
1385
fn scrolling_the_inspector_stops_at_the_end() {
1386
    let (mut app, control, _rx) = with_a_diff();
1387
    let mut term = terminal_of(90, HEIGHT);
1388
    app.draw(&mut term).unwrap();
1389
    for _ in 0..200 {
1390
        app.on_key(&key(KeyCode::PageDown), 90, &control);
1391
    }
1392
    app.draw(&mut term).unwrap();
1393
    let frame = screen(&term);
1394
    assert!(
1395
        frame.lines().any(|line| line.contains("end")),
1396
        "the pane scrolled past everything it had:\n{frame}"
1397
    );
1398
}
1399
1400
/// `/diff` against a real repository, through the real actor. This is the
1401
/// producer half: git is run, its output parsed, and the inspector opened.
1402
#[tokio::test]
1403
async fn slash_diff_shows_what_changed_in_a_real_repository() {
1404
    let dir = tempfile::tempdir().expect("a temporary directory");
1405
    let repo = dir.path();
1406
    let git = |args: &[&str]| {
1407
        std::process::Command::new("git")
1408
            .args(args)
1409
            .current_dir(repo)
1410
            .output()
1411
            .expect("git")
1412
    };
1413
    git(&["init", "-q"]);
1414
    git(&["config", "user.email", "t@example.com"]);
1415
    git(&["config", "user.name", "Test"]);
1416
    std::fs::write(repo.join("thing.txt"), "keep\nold line\ntail\n").unwrap();
1417
    git(&["add", "."]);
1418
    git(&["commit", "-qm", "first"]);
1419
    std::fs::write(repo.join("thing.txt"), "keep\nnew line\ntail\n").unwrap();
1420
1421
    let files = openagents_cli::interactive::collect_diff(&[], repo)
1422
        .await
1423
        .expect("a diff from git");
1424
    assert_eq!(files.len(), 1, "{files:?}");
1425
    assert_eq!(files[0].path, "thing.txt");
1426
    assert_eq!(files[0].stats(), (1, 1));
1427
1428
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha)
1429
        .with_working_directory(repo.to_path_buf());
1430
    let mut term = terminal_of(90, HEIGHT);
1431
    app.on_turn_event(TurnEvent::Diff(files));
1432
    app.draw(&mut term).unwrap();
1433
    let frame = screen(&term);
1434
    assert!(frame.contains("thing.txt"), "{frame}");
1435
    assert!(frame.contains("\u{2212} old line"), "{frame}");
1436
    assert!(frame.contains("+ new line"), "{frame}");
1437
}
1438
1439
/// Two files named directly are compared by this program, not by git, so
1440
/// `/diff` works on files that are not in a repository at all.
1441
#[tokio::test]
1442
async fn slash_diff_with_two_paths_compares_the_two_files() {
1443
    let dir = tempfile::tempdir().expect("a temporary directory");
1444
    std::fs::write(dir.path().join("before.txt"), "one\ntwo\nthree\n").unwrap();
1445
    std::fs::write(dir.path().join("after.txt"), "one\nTWO\nthree\nfour\n").unwrap();
1446
1447
    let files = openagents_cli::interactive::collect_diff(
1448
        &["before.txt".to_string(), "after.txt".to_string()],
1449
        dir.path(),
1450
    )
1451
    .await
1452
    .expect("a diff of the two files");
1453
1454
    assert_eq!(files.len(), 1);
1455
    assert_eq!(files[0].stats(), (2, 1));
1456
    assert_eq!(files[0].renamed_from.as_deref(), Some("before.txt"));
1457
}
1458
1459
/// A directory git knows nothing about is a refusal with a reason, not a
1460
/// silent empty inspector.
1461
#[tokio::test]
1462
async fn slash_diff_outside_a_repository_says_why_it_cannot() {
1463
    let dir = tempfile::tempdir().expect("a temporary directory");
1464
    let result = openagents_cli::interactive::collect_diff(&[], dir.path()).await;
1465
    let message = result.expect_err("git should have refused here");
1466
    assert!(
1467
        message.to_lowercase().contains("git"),
1468
        "the refusal does not say what refused: {message}"
1469
    );
1470
}
1471
1472
// ------------------------------------------------- programs under a terminal
1473
1474
use openagents_cli::pty::PtyControl;
1475
use ratatui::layout::Rect;
1476
use std::sync::{Arc, Mutex};
1477
use std::time::Duration;
1478
1479
/// A session whose runtime actor is real but whose model is unreachable.
1480
///
1481
/// `/run` and `/diff` never touch the model, so the actor these tests drive is
1482
/// the production one and only the inference host is absent.
1483
fn actor_session() -> CoderRuntimeSession {
1484
    CoderRuntimeSession::new(
1485
        Lane::OxAlpha,
1486
        // Reserved by RFC 6890 as "this host on this network": nothing here
1487
        // reaches it, and a test that accidentally tried would fail loudly.
1488
        Some("http://192.0.2.1:9/api/v1".to_string()),
1489
        None,
1490
        HarnessToolRegistry::new(Some(std::env::temp_dir())),
1491
    )
1492
}
1493
1494
/// Deliver turn events to `app` until `done` holds or the deadline passes.
1495
async fn pump<F>(
1496
    app: &mut CoderApp,
1497
    turns: &mut UnboundedReceiver<TurnEvent>,
1498
    done: F,
1499
    within: Duration,
1500
) where
1501
    F: Fn(&CoderApp) -> bool,
1502
{
1503
    let deadline = tokio::time::Instant::now() + within;
1504
    loop {
1505
        if done(app) {
1506
            return;
1507
        }
1508
        let left = deadline.saturating_duration_since(tokio::time::Instant::now());
1509
        if left.is_zero() {
1510
            return;
1511
        }
1512
        match tokio::time::timeout(left, turns.recv()).await {
1513
            Ok(Some(event)) => app.on_turn_event(event),
1514
            _ => return,
1515
        }
1516
    }
1517
}
1518
1519
/// Start a session, run `command` in it, and pump until `done`.
1520
async fn run_in_a_pane<F>(
1521
    command: &str,
1522
    size: (u16, u16),
1523
    done: F,
1524
    within: Duration,
1525
) -> (
1526
    CoderApp,
1527
    Terminal<TestBackend>,
1528
    UnboundedReceiver<TurnEvent>,
1529
)
1530
where
1531
    F: Fn(&CoderApp) -> bool,
1532
{
1533
    let (control_tx, control_rx) = unbounded_channel::<Control>();
1534
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
1535
    tokio::spawn(runtime_actor(actor_session(), control_rx, turn_tx.clone()));
1536
1537
    let mut term = terminal_of(size.0, size.1);
1538
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
1539
    // The frame's size is what the child is told, so it has to be known before
1540
    // the child starts.
1541
    app.draw(&mut term).unwrap();
1542
    app.submit(command.to_string(), &control_tx);
1543
1544
    pump(&mut app, &mut turn_rx, done, within).await;
1545
    app.draw(&mut term).unwrap();
1546
    (app, term, turn_rx)
1547
}
1548
1549
/// The claim this whole module exists for: the child is on a terminal.
1550
///
1551
/// `tty` prints the terminal it is attached to, and prints `not a tty` when it
1552
/// is attached to a pipe. Under the buffered `Command::output` the crate used
1553
/// everywhere else, this test would print the second.
1554
#[tokio::test]
1555
async fn a_program_run_in_the_frame_is_on_a_real_terminal() {
1556
    let (_app, term, _rx) = run_in_a_pane(
1557
        "/run tty",
1558
        (80, 24),
1559
        |app| app.pty_exit().is_some(),
1560
        Duration::from_secs(10),
1561
    )
1562
    .await;
1563
1564
    let frame = screen(&term);
1565
    assert!(
1566
        frame.contains("/dev/"),
1567
        "the child did not report a terminal:\n{frame}"
1568
    );
1569
    assert!(
1570
        !frame.contains("not a tty"),
1571
        "the child was given a pipe, not a pseudoterminal:\n{frame}"
1572
    );
1573
    assert!(frame.contains("Run \u{b7} tty"), "{frame}");
1574
}
1575
1576
/// The child is told the size of the pane it is drawn into, and no other size.
1577
///
1578
/// `stty size` asks the kernel for the window size of its terminal, which only
1579
/// exists because there is a terminal. An 80x24 frame leaves 78 columns and 16
1580
/// rows inside the header, the status bar, and the pane's own rules.
1581
#[tokio::test]
1582
async fn the_program_is_told_the_size_of_the_pane_it_is_drawn_into() {
1583
    let (_app, term, _rx) = run_in_a_pane(
1584
        "/run stty size",
1585
        (80, 24),
1586
        |app| app.pty_exit().is_some(),
1587
        Duration::from_secs(10),
1588
    )
1589
    .await;
1590
1591
    assert!(
1592
        screen(&term).contains("16 78"),
1593
        "the child was told the wrong window size:\n{}",
1594
        screen(&term)
1595
    );
1596
}
1597
1598
/// Colour survives the trip: the child emits an SGR sequence and the frame
1599
/// draws the cell in that colour. A pipe would have made most programs drop it.
1600
#[tokio::test]
1601
async fn colour_the_program_writes_reaches_the_frame() {
1602
    use ratatui::style::Color;
1603
    let (_app, term, _rx) = run_in_a_pane(
1604
        r"/run printf \033[31mRED\033[0m",
1605
        (80, 24),
1606
        |app| app.pty_exit().is_some(),
1607
        Duration::from_secs(10),
1608
    )
1609
    .await;
1610
1611
    assert_eq!(
1612
        cell_in_the_pane(&term, "RED").fg,
1613
        Color::Red,
1614
        "the colour the child asked for was not drawn:\n{}",
1615
        screen(&term)
1616
    );
1617
}
1618
1619
/// A full-screen program: it clears the screen, moves the cursor, and draws.
1620
/// Nothing of that works down a pipe.
1621
#[tokio::test]
1622
async fn a_program_that_draws_a_screen_is_drawn_where_it_asked_to_be() {
1623
    let (_app, term, _rx) = run_in_a_pane(
1624
        // Cursor addressing without a semicolon in it: `5d` is line-position
1625
        // absolute and `10G` is column absolute. A `;` would send the line to
1626
        // a shell, which would then try to glob `[2J`.
1627
        r"/run printf \033[2J\033[5d\033[10Gmiddle",
1628
        (80, 24),
1629
        |app| app.pty_exit().is_some(),
1630
        Duration::from_secs(10),
1631
    )
1632
    .await;
1633
1634
    // The pane's inner area starts at column 1 and row 4 of the frame, and the
1635
    // child asked for row 5, column 10 of its own screen.
1636
    assert_eq!(
1637
        position_from(&term, "middle", PANE_TOP),
1638
        (1 + 9, 4 + 4),
1639
        "\n{}",
1640
        screen(&term)
1641
    );
1642
}
1643
1644
/// Keys typed reach the program, and `Ctrl+]` takes the keyboard back.
1645
#[tokio::test]
1646
async fn keys_reach_the_program_and_ctrl_bracket_takes_them_back() {
1647
    // Wide enough that the status bar has room for its hint; the narrow case
1648
    // is covered where the dropping rule is.
1649
    const WIDE: u16 = 140;
1650
    let (control_tx, control_rx) = unbounded_channel::<Control>();
1651
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
1652
    tokio::spawn(runtime_actor(actor_session(), control_rx, turn_tx.clone()));
1653
1654
    let mut term = terminal_of(WIDE, HEIGHT);
1655
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
1656
    app.draw(&mut term).unwrap();
1657
    app.submit("/run cat".to_string(), &control_tx);
1658
    pump(
1659
        &mut app,
1660
        &mut turn_rx,
1661
        |app| app.running(),
1662
        Duration::from_secs(10),
1663
    )
1664
    .await;
1665
1666
    // The bar offers exactly the key that works here, and no other.
1667
    app.draw(&mut term).unwrap();
1668
    let frame = screen(&term);
1669
    assert!(frame.contains("Ctrl+]: stop and go back"), "{frame}");
1670
    assert!(!frame.contains("Enter: send"), "{frame}");
1671
    assert!(frame.contains("Status: running"), "{frame}");
1672
1673
    // `cat` echoes a line once its terminal has one to give it.
1674
    for ch in "ping".chars() {
1675
        app.on_key(&key(KeyCode::Char(ch)), WIDE, &control_tx);
1676
    }
1677
    app.on_key(&key(KeyCode::Enter), WIDE, &control_tx);
1678
    pump(
1679
        &mut app,
1680
        &mut turn_rx,
1681
        |app| {
1682
            app.pty_text()
1683
                .is_some_and(|text| text.matches("ping").count() >= 2)
1684
        },
1685
        Duration::from_secs(10),
1686
    )
1687
    .await;
1688
    app.draw(&mut term).unwrap();
1689
    assert!(
1690
        screen(&term).matches("ping").count() >= 2,
1691
        "the keys did not reach the program:\n{}",
1692
        screen(&term)
1693
    );
1694
1695
    // Ctrl+] ends it and hands the keyboard back to the composer.
1696
    app.on_key(
1697
        &KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL),
1698
        WIDE,
1699
        &control_tx,
1700
    );
1701
    app.draw(&mut term).unwrap();
1702
    let frame = screen(&term);
1703
    assert!(!app.running(), "the program was not stopped");
1704
    assert!(frame.contains("Stopped"), "{frame}");
1705
    assert!(
1706
        frame.contains("Message"),
1707
        "the composer did not come back:\n{frame}"
1708
    );
1709
    assert!(!app.should_exit(), "Ctrl+] ended the session");
1710
}
1711
1712
/// Esc belongs to the program, not to the session. A full-screen program that
1713
/// could not receive Esc would be unusable, and a session that exited on it
1714
/// would take the reader out of their editor.
1715
#[tokio::test]
1716
async fn esc_goes_to_the_program_rather_than_ending_the_session() {
1717
    let (control_tx, control_rx) = unbounded_channel::<Control>();
1718
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
1719
    tokio::spawn(runtime_actor(actor_session(), control_rx, turn_tx.clone()));
1720
1721
    let mut term = terminal();
1722
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
1723
    app.draw(&mut term).unwrap();
1724
    app.submit("/run cat".to_string(), &control_tx);
1725
    pump(
1726
        &mut app,
1727
        &mut turn_rx,
1728
        |app| app.running(),
1729
        Duration::from_secs(10),
1730
    )
1731
    .await;
1732
1733
    app.on_key(&key(KeyCode::Esc), WIDTH, &control_tx);
1734
    assert!(!app.should_exit(), "Esc over a running program exited");
1735
    assert!(app.running());
1736
1737
    app.on_key(
1738
        &KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL),
1739
        WIDTH,
1740
        &control_tx,
1741
    );
1742
}
1743
1744
/// A program that ends leaves its output up, because that output is usually
1745
/// the answer, and says how it ended.
1746
#[tokio::test]
1747
async fn a_program_that_fails_reports_its_exit_code() {
1748
    let (mut app, term, _rx) = run_in_a_pane(
1749
        "/run sh -c \"exit 3\"",
1750
        (140, 24),
1751
        |app| app.pty_exit().is_some(),
1752
        Duration::from_secs(10),
1753
    )
1754
    .await;
1755
1756
    assert_eq!(app.pty_exit(), Some(3));
1757
    assert!(screen(&term).contains("exited 3"), "{}", screen(&term));
1758
    // The bar stops offering the key that stops a program that has stopped.
1759
    assert!(
1760
        screen(&term).contains("Enter: go back"),
1761
        "{}",
1762
        screen(&term)
1763
    );
1764
    assert!(!screen(&term).contains("Ctrl+]"), "{}", screen(&term));
1765
1766
    let (tx, _rx2) = tokio::sync::mpsc::unbounded_channel();
1767
    let mut term = term;
1768
    app.on_key(&key(KeyCode::Enter), 140, &tx);
1769
    app.draw(&mut term).unwrap();
1770
    assert!(
1771
        screen(&term).contains("exited with code 3"),
1772
        "{}",
1773
        screen(&term)
1774
    );
1775
    assert!(screen(&term).contains("Message"), "{}", screen(&term));
1776
}
1777
1778
/// A command that does not exist is reported, and the session stays open.
1779
#[tokio::test]
1780
async fn a_command_that_is_not_there_is_reported_rather_than_hanging() {
1781
    let (control_tx, control_rx) = unbounded_channel::<Control>();
1782
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
1783
    tokio::spawn(runtime_actor(actor_session(), control_rx, turn_tx.clone()));
1784
1785
    let mut term = terminal_of(100, HEIGHT);
1786
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
1787
    app.draw(&mut term).unwrap();
1788
    app.submit(
1789
        "/run this-program-does-not-exist-anywhere".to_string(),
1790
        &control_tx,
1791
    );
1792
    pump(
1793
        &mut app,
1794
        &mut turn_rx,
1795
        |app| app.entries().iter().any(|e| e.text.contains("Could not")),
1796
        Duration::from_secs(10),
1797
    )
1798
    .await;
1799
    app.draw(&mut term).unwrap();
1800
1801
    assert!(!app.running());
1802
    assert!(
1803
        screen(&term).contains("Could not run it"),
1804
        "{}",
1805
        screen(&term)
1806
    );
1807
}
1808
1809
/// What a resize does to the program's side, without depending on a signal
1810
/// arriving in a test's timing window.
1811
#[derive(Debug, Default)]
1812
struct RecordingControl {
1813
    sizes: Mutex<Vec<(u16, u16)>>,
1814
    killed: Mutex<bool>,
1815
    written: Mutex<Vec<u8>>,
1816
}
1817
1818
impl PtyControl for RecordingControl {
1819
    fn write(&self, bytes: &[u8]) {
1820
        self.written.lock().unwrap().extend_from_slice(bytes);
1821
    }
1822
    fn resize(&self, cols: u16, rows: u16) {
1823
        self.sizes.lock().unwrap().push((cols, rows));
1824
    }
1825
    fn kill(&self) {
1826
        *self.killed.lock().unwrap() = true;
1827
    }
1828
}
1829
1830
#[test]
1831
fn resizing_the_window_resizes_the_program() {
1832
    let (mut app, control, _rx) = app();
1833
    let recorder = Arc::new(RecordingControl::default());
1834
    app.on_turn_event(TurnEvent::PtyOpen {
1835
        label: "cat".to_string(),
1836
        control: recorder.clone(),
1837
    });
1838
1839
    let mut term = terminal_of(80, 24);
1840
    app.draw(&mut term).unwrap();
1841
    // The first draw sets the pane's size; nothing has changed yet, so nothing
1842
    // is sent — a resize the child did not need is a signal it did not need.
1843
    assert!(recorder.sizes.lock().unwrap().is_empty());
1844
1845
    app.on_size(Rect::new(0, 0, 100, 30));
1846
    assert_eq!(
1847
        recorder.sizes.lock().unwrap().as_slice(),
1848
        &[(98, 22)],
1849
        "the new window size did not reach the program"
1850
    );
1851
1852
    // And drawing at that size again does not send it twice.
1853
    app.on_size(Rect::new(0, 0, 100, 30));
1854
    assert_eq!(recorder.sizes.lock().unwrap().len(), 1);
1855
    let _ = control;
1856
}
1857
1858
#[test]
1859
fn a_key_over_a_running_program_is_sent_as_the_bytes_a_terminal_would_send() {
1860
    let (mut app, control, _rx) = app();
1861
    let recorder = Arc::new(RecordingControl::default());
1862
    app.on_turn_event(TurnEvent::PtyOpen {
1863
        label: "cat".to_string(),
1864
        control: recorder.clone(),
1865
    });
1866
1867
    app.on_key(&key(KeyCode::Char('h')), WIDTH, &control);
1868
    app.on_key(&key(KeyCode::Enter), WIDTH, &control);
1869
    app.on_key(
1870
        &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
1871
        WIDTH,
1872
        &control,
1873
    );
1874
    assert_eq!(recorder.written.lock().unwrap().as_slice(), b"h\r\x03");
1875
    assert!(!*recorder.killed.lock().unwrap());
1876
1877
    app.on_key(
1878
        &KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL),
1879
        WIDTH,
1880
        &control,
1881
    );
1882
    assert!(*recorder.killed.lock().unwrap(), "Ctrl+] did not stop it");
1883
}
1884
1885
/// The whole loop, over a real pseudoterminal: keys in at the top, a program
1886
/// run, and its ending reported on the transcript.
1887
#[tokio::test]
1888
async fn end_to_end_over_the_loop_running_a_program_under_a_terminal() {
1889
    let (control_tx, control_rx) = unbounded_channel::<Control>();
1890
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
1891
    tokio::spawn(runtime_actor(actor_session(), control_rx, turn_tx.clone()));
1892
1893
    let mut term = terminal_of(90, HEIGHT);
1894
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
1895
    let (keys_tx, keys_rx) = unbounded_channel();
1896
1897
    send_keys(&keys_tx, "/run tty");
1898
    let _ = keys_tx.send(Event::Key(key(KeyCode::Enter)));
1899
1900
    // Once the program has ended, dismiss its pane and leave.
1901
    let keys_for_exit = keys_tx.clone();
1902
    tokio::spawn(async move {
1903
        tokio::time::sleep(Duration::from_millis(1500)).await;
1904
        let _ = keys_for_exit.send(Event::Key(key(KeyCode::Enter)));
1905
        tokio::time::sleep(Duration::from_millis(200)).await;
1906
        let _ = keys_for_exit.send(Event::Key(key(KeyCode::Esc)));
1907
    });
1908
1909
    drive(
1910
        &mut app,
1911
        &mut term,
1912
        keys_rx,
1913
        control_tx,
1914
        &mut turn_rx,
1915
        turn_tx,
1916
    )
1917
    .await;
1918
1919
    let frame = screen(&term);
1920
    assert!(app.should_exit(), "the loop did not exit");
1921
    assert!(
1922
        frame.contains("`tty` finished."),
1923
        "the program did not run to completion through the loop:\n{frame}"
1924
    );
1925
}
1926
1927
/// The resize reaches the running program as a signal, not just as a number.
1928
///
1929
/// The shell traps `SIGWINCH` and prints the window size the kernel now
1930
/// reports. Nothing about that is emulated: the size is set with the same
1931
/// `TIOCSWINSZ` a terminal emulator uses, and the kernel is what raises the
1932
/// signal.
1933
#[tokio::test]
1934
async fn resizing_the_frame_signals_the_running_program() {
1935
    let (control_tx, control_rx) = unbounded_channel::<Control>();
1936
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
1937
    tokio::spawn(runtime_actor(actor_session(), control_rx, turn_tx.clone()));
1938
1939
    let mut term = terminal_of(80, 24);
1940
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
1941
    app.draw(&mut term).unwrap();
1942
    app.submit(
1943
        "/run trap 'stty size' WINCH; stty size; for i in 1 2 3 4 5 6 7 8 9 10; do sleep 0.3; done"
1944
            .to_string(),
1945
        &control_tx,
1946
    );
1947
1948
    let says = |needle: &'static str| {
1949
        move |app: &CoderApp| app.pty_text().is_some_and(|text| text.contains(needle))
1950
    };
1951
    pump(
1952
        &mut app,
1953
        &mut turn_rx,
1954
        says("16 78"),
1955
        Duration::from_secs(10),
1956
    )
1957
    .await;
1958
    app.draw(&mut term).unwrap();
1959
    assert!(
1960
        screen(&term).contains("16 78"),
1961
        "the program never reported its starting size:\n{}",
1962
        screen(&term)
1963
    );
1964
1965
    // Widen the window. `draw` is what notices, and what tells the child.
1966
    term.backend_mut().resize(100, 30);
1967
    app.draw(&mut term).unwrap();
1968
1969
    pump(
1970
        &mut app,
1971
        &mut turn_rx,
1972
        says("22 98"),
1973
        Duration::from_secs(10),
1974
    )
1975
    .await;
1976
    app.draw(&mut term).unwrap();
1977
    assert!(
1978
        screen(&term).contains("22 98"),
1979
        "the resize did not reach the program as a signal:\n{}",
1980
        screen(&term)
1981
    );
1982
1983
    app.on_key(
1984
        &KeyEvent::new(KeyCode::Char(']'), KeyModifiers::CONTROL),
1985
        100,
1986
        &control_tx,
1987
    );
1988
}
1989
1990
/// Every key the inspector's own status bar names has to do something too.
1991
#[test]
1992
fn every_key_the_inspectors_status_bar_names_does_something() {
1993
    let (mut app, control, _rx) = with_a_diff();
1994
    let mut term = terminal_of(200, HEIGHT);
1995
    app.draw(&mut term).unwrap();
1996
    let frame = screen(&term);
1997
    for hint in [
1998
        "Esc: close",
1999
        "v: change view",
2000
        "Tab: next file",
2001
        "\u{2191}\u{2193} PgUp/PgDn: scroll",
2002
    ] {
2003
        assert!(
2004
            frame.contains(hint),
2005
            "the bar does not offer {hint}:\n{frame}"
2006
        );
2007
    }
2008
2009
    // v changes the view.
2010
    app.on_key(&key(KeyCode::Char('v')), 200, &control);
2011
    app.draw(&mut term).unwrap();
2012
    assert!(screen(&term).contains("side by side"), "{}", screen(&term));
2013
2014
    // Tab changes the file.
2015
    app.on_key(&key(KeyCode::Tab), 200, &control);
2016
    app.draw(&mut term).unwrap();
2017
    assert!(screen(&term).contains("2 of 2"), "{}", screen(&term));
2018
2019
    // Down scrolls, and Up comes back.
2020
    app.on_key(&key(KeyCode::Down), 200, &control);
2021
    app.draw(&mut term).unwrap();
2022
    let scrolled = screen(&term);
2023
    assert!(
2024
        !scrolled.contains("README.md  +1"),
2025
        "Down did not scroll the header off:\n{scrolled}"
2026
    );
2027
    app.on_key(&key(KeyCode::Up), 200, &control);
2028
    app.draw(&mut term).unwrap();
2029
    assert!(screen(&term).contains("README.md  +1"), "{}", screen(&term));
2030
2031
    // PgDn moves further than Down did.
2032
    app.on_key(&key(KeyCode::PageDown), 200, &control);
2033
    app.on_key(&key(KeyCode::PageUp), 200, &control);
2034
    app.draw(&mut term).unwrap();
2035
    assert!(screen(&term).contains("README.md  +1"), "{}", screen(&term));
2036
2037
    // Esc closes.
2038
    app.on_key(&key(KeyCode::Esc), 200, &control);
2039
    assert!(!app.inspecting());
2040
    assert!(!app.should_exit());
2041
}
2042
2043
/// The status bar's model and token counts, over the real stack: a real socket
2044
/// speaking real server-sent events, through the real `runtime_actor`.
2045
///
2046
/// The model is read from the session's `last_model` rather than from its
2047
/// grant, which is what makes the local lane — which never opens a grant —
2048
/// report a model at all.
2049
#[tokio::test]
2050
async fn end_to_end_over_real_http_the_bar_reports_the_model_and_the_tokens() {
2051
    let stub = support::start_reporting_usage(vec!["Done."], (128, 64, 192)).await;
2052
2053
    let session = CoderRuntimeSession::new(
2054
        Lane::OxAlpha,
2055
        Some(stub.base),
2056
        Some("oat_test".to_string()),
2057
        HarnessToolRegistry::new(Some(std::env::temp_dir())),
2058
    );
2059
2060
    let (control_tx, control_rx) = unbounded_channel::<Control>();
2061
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
2062
    tokio::spawn(runtime_actor(session, control_rx, turn_tx.clone()));
2063
2064
    let mut term = terminal_of(140, HEIGHT);
2065
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha);
2066
    let (keys_tx, keys_rx) = unbounded_channel();
2067
    send_keys(&keys_tx, "how much");
2068
    let _ = keys_tx.send(Event::Key(key(KeyCode::Enter)));
2069
2070
    let keys_for_exit = keys_tx.clone();
2071
    tokio::spawn(async move {
2072
        tokio::time::sleep(Duration::from_millis(2000)).await;
2073
        let _ = keys_for_exit.send(Event::Key(key(KeyCode::Esc)));
2074
    });
2075
2076
    drive(
2077
        &mut app,
2078
        &mut term,
2079
        keys_rx,
2080
        control_tx,
2081
        &mut turn_rx,
2082
        turn_tx,
2083
    )
2084
    .await;
2085
2086
    let row = status_row(&term);
2087
    assert!(row.contains("Model: ox-alpha"), "{row}");
2088
    assert!(
2089
        row.contains("Tokens: 128+64=192"),
2090
        "the tokens the server reported are not on the bar: {row}"
2091
    );
2092
    assert_eq!(
2093
        app.usage().total_tokens,
2094
        192,
2095
        "the session did not carry the reported usage"
2096
    );
2097
}
2098
2099
/// `/diff` end to end: typed into the composer, run by the real actor, and
2100
/// opened in the inspector. The producer half of the inspector, over the same
2101
/// channel the session uses.
2102
#[tokio::test]
2103
async fn slash_diff_typed_into_the_composer_opens_the_inspector() {
2104
    let dir = tempfile::tempdir().expect("a temporary directory");
2105
    let repo = dir.path();
2106
    let git = |args: &[&str]| {
2107
        std::process::Command::new("git")
2108
            .args(args)
2109
            .current_dir(repo)
2110
            .output()
2111
            .expect("git")
2112
    };
2113
    git(&["init", "-q"]);
2114
    git(&["config", "user.email", "t@example.com"]);
2115
    git(&["config", "user.name", "Test"]);
2116
    std::fs::write(repo.join("thing.txt"), "keep\nold line\ntail\n").unwrap();
2117
    git(&["add", "."]);
2118
    git(&["commit", "-qm", "first"]);
2119
    std::fs::write(repo.join("thing.txt"), "keep\nnew line\ntail\n").unwrap();
2120
2121
    // The actor runs git in the process's own working directory, so the test
2122
    // runs from the repository it is asking about.
2123
    let previous = std::env::current_dir().expect("a working directory");
2124
    std::env::set_current_dir(repo).expect("move into the repository");
2125
2126
    let (control_tx, control_rx) = unbounded_channel::<Control>();
2127
    let (turn_tx, mut turn_rx) = unbounded_channel::<TurnEvent>();
2128
    tokio::spawn(runtime_actor(actor_session(), control_rx, turn_tx.clone()));
2129
2130
    let mut term = terminal_of(90, HEIGHT);
2131
    let mut app = CoderApp::new("openagents coder", &Lane::OxAlpha)
2132
        .with_working_directory(repo.to_path_buf());
2133
    type_str(&mut app, &control_tx, "/diff");
2134
    app.on_key(&key(KeyCode::Enter), 90, &control_tx);
2135
2136
    pump(
2137
        &mut app,
2138
        &mut turn_rx,
2139
        |app| app.inspecting(),
2140
        Duration::from_secs(20),
2141
    )
2142
    .await;
2143
    std::env::set_current_dir(previous).expect("go back");
2144
2145
    app.draw(&mut term).unwrap();
2146
    let frame = screen(&term);
2147
    assert!(
2148
        app.inspecting(),
2149
        "`/diff` never opened the inspector:\n{frame}"
2150
    );
2151
    assert!(frame.contains("thing.txt"), "{frame}");
2152
    assert!(frame.contains("\u{2212} old line"), "{frame}");
2153
    assert!(frame.contains("+ new line"), "{frame}");
2154
}
crates/openagents-cli/tests/support/mod.rs modified +32

@@ -22,6 +22,23 @@ pub struct StubProxy {

22 22
pub async fn start(
23 23
    chunks: Vec<&'static str>,
24 24
    gate: Option<tokio::sync::oneshot::Receiver<()>>,
25
) -> StubProxy {
26
    start_with(chunks, gate, None).await
27
}
28
29
/// The same stub, with a final `usage` chunk of (prompt, completion, total).
30
///
31
/// The real proxy sends usage on a chunk of its own with an empty `choices`
32
/// array, after the content and before `[DONE]`; this sends it the same way,
33
/// so what a test asserts about the status bar went through the same parse.
34
pub async fn start_reporting_usage(chunks: Vec<&'static str>, usage: (u64, u64, u64)) -> StubProxy {
35
    start_with(chunks, None, Some(usage)).await
36
}
37
38
async fn start_with(
39
    chunks: Vec<&'static str>,
40
    gate: Option<tokio::sync::oneshot::Receiver<()>>,
41
    usage: Option<(u64, u64, u64)>,
25 42
) -> StubProxy {
26 43
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
27 44
    let port = listener.local_addr().unwrap().port();

@@ -70,6 +87,21 @@ pub async fn start(

70 87
                        }
71 88
                    }
72 89
                }
90
                if let Some((prompt, completion, total)) = usage {
91
                    let frame = format!(
92
                        "data: {}\n\n",
93
                        serde_json::json!({
94
                            "choices": [],
95
                            "usage": {
96
                                "prompt_tokens": prompt,
97
                                "completion_tokens": completion,
98
                                "total_tokens": total,
99
                            }
100
                        })
101
                    );
102
                    let _ = socket.write_all(frame.as_bytes()).await;
103
                    let _ = socket.flush().await;
104
                }
73 105
                let _ = socket.write_all(b"data: [DONE]\n\n").await;
74 106
                let _ = socket.flush().await;
75 107
                continue;

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