quvyta-cli 0.1.0-alpha.5

A small coding agent in the terminal: chat with a model that reads, searches, edits and runs in one folder, and asks before it changes anything (alpha)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
//! The chat driven the way a person drives it: typing, Enter, the buttons on the card and Esc,
//! against a stand-in provider on the loopback.

use std::path::{Path, PathBuf};

use qframe::color::ColorDepth;
use qframe::env::{AssetDirs, Env};
use qframe::event::MouseKind;
use qframe::icons::GlyphMode;
use serde_json::{Value, json};

use super::*;
use crate::provider::fake::{FakeServer, calls, says};

/// Not a key of anyone's: the characters spell what it is.
const MADE_UP: &str = "not-a-real-key-0000-wxyz";

/// A folder of its own for one test: the working folder, the conversations and the settings
/// live under it, and it is removed when the test ends.
pub(crate) struct Place {
    pub(crate) base: PathBuf,
}

impl Place {
    pub(crate) fn new(name: &str) -> Self {
        // The test's name and a number of its own: two tests that picked one name, running at
        // once, still never share a folder.
        static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
        let number = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        let base = std::env::temp_dir().join(format!("qcli-app-{name}-{}-{number}", std::process::id()));
        let _ = std::fs::remove_dir_all(&base);
        std::fs::create_dir_all(base.join("work")).expect("the working folder");
        std::fs::create_dir_all(base.join("config")).expect("the settings folder");
        let key = base.join("key");
        std::fs::write(&key, format!("{MADE_UP}\n")).expect("the key file");
        // As a person keeps a key file: theirs alone.
        std::fs::set_permissions(&key, std::os::unix::fs::PermissionsExt::from_mode(0o600)).expect("private");
        Self { base }
    }

    pub(crate) fn work(&self) -> PathBuf {
        self.base.join("work")
    }

    /// The folder everything of the test's lives under, for a test that has to look at the
    /// settings or the conversations before the app is built.
    pub(crate) fn base(&self) -> &Path {
        &self.base
    }

    pub(crate) fn store(&self) -> Store {
        let workspace = Workspace::new(&self.work()).expect("workspace");
        Store::new(&self.base.join("conversations"), workspace.root())
    }

    pub(crate) fn settings(&self, address: Option<&str>) -> Settings {
        let mut settings = config::load_in(&self.base.join("config"));
        if let Some(address) = address {
            Provider {
                address: address.to_owned(),
                model: "test-model".into(),
                key_file: self.base.join("key").display().to_string(),
                key_header: "api-key".into(),
                source: config::Source::Settings,
            }
            .write(&mut settings);
        }
        settings
    }

    pub(crate) fn app(&self, address: Option<&str>) -> QCli {
        QCli::new(Workspace::new(&self.work()).expect("workspace"), self.store(), self.settings(address))
    }
}

/// qcli opened from the runtime `run()` starts, with `folder` as its settings folder, at the size
/// the chat tests use. The terminal is one that says it draws every colour, as the terminals qcli
/// is used in do; the runtime would ask the real one.
pub(crate) fn opened(app: QCli, folder: &Path) -> Harness<QCli> {
    let mut harness = runtime(app).harness_in(folder, 100, 40).expect("the runtime opens");
    harness.set_depth(ColorDepth::TrueColor);
    harness
}

impl Drop for Place {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.base);
    }
}

fn env() -> Env {
    let dirs = AssetDirs {
        locale_sources: locales(),
        keymap_source: Some(("keymap.toml".to_owned(), KEYMAP.to_owned())),
        ..AssetDirs::default()
    };
    Env::load(&dirs).expect("the built-in files load")
}

pub(crate) fn harness(app: QCli) -> Harness<QCli> {
    let mut harness = Harness::with_env(app, env(), 100, 40);
    harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode).set_reduced_motion(true);
    harness
}

/// The same chat in an environment of the test's own: the language files and the keymap the test
/// names, so what the screen says can only have come from there.
fn harness_with(app: QCli, locales: Vec<(String, String)>, keymap: &str) -> Harness<QCli> {
    let dirs = AssetDirs {
        locale_sources: locales,
        keymap_source: Some(("keymap.toml".to_owned(), keymap.to_owned())),
        ..AssetDirs::default()
    };
    let env = Env::load(&dirs).expect("the test's own files load");
    let mut harness = Harness::with_env(app, env, 100, 40);
    harness.set_locale("en").set_glyph_mode(GlyphMode::Unicode).set_reduced_motion(true);
    harness
}

/// The application's own language files.
fn locales() -> Vec<(String, String)> {
    crate::locales().iter().map(|(file, text)| ((*file).to_owned(), (*text).to_owned())).collect()
}

/// The application's language files with the English one replaced by `english`, so a test can
/// give one key a word of its own and see whether the screen asks for it.
fn locales_with_english(english: &str) -> Vec<(String, String)> {
    let mut files = locales();
    files.retain(|(file, _)| file != "en.toml");
    files.insert(0, ("en.toml".to_owned(), english.to_owned()));
    files
}

/// Types `text` in the writing field and presses Enter.
pub(crate) fn say(harness: &mut Harness<QCli>, text: &str) {
    harness.type_text(text).press("enter");
}

/// The last message of the request the server received as number `index`.
fn last_message(server: &FakeServer, index: usize) -> Value {
    server.received()[index].body["messages"]
        .as_array()
        .and_then(|messages| messages.last().cloned())
        .expect("a message")
}

fn saved(place: &Place) -> String {
    std::fs::read_to_string(place.store().file()).expect("the conversation is saved")
}

/// The name in the header is the one the language file carries, in the language on screen: the
/// test's English file gives `app.name` a word of its own and the header has to show that word.
#[test]
fn the_name_in_the_header_comes_from_the_language_file() {
    let place = Place::new("name");
    let (file, text) = crate::locales()[0];
    assert_eq!(file, "en.toml");
    let named = text.replacen("name = \"qcli\"", "name = \"the-test-name\"", 1);
    assert_ne!(named, text, "the test's file really carries another name: {named}");

    let harness = harness_with(place.app(None), locales_with_english(&named), KEYMAP);
    let screen = harness.screen();
    assert!(screen.contains("the-test-name"), "the header asks the language file:\n{screen}");
    assert!(!screen.contains(" qcli "), "and writes no name of its own:\n{screen}");
}

#[test]
fn a_message_goes_out_and_the_reply_streams_onto_the_screen_and_into_the_file() {
    let place = Place::new("chat");
    let server = FakeServer::start(vec![(200, says("Hello from the stand-in."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    assert!(harness.screen().contains("Ask anything about this folder"));
    say(&mut harness, "hello there");

    assert!(harness.screen().contains("hello there"), "{}", harness.screen());
    assert!(harness.screen().contains("Hello from the stand-in."), "{}", harness.screen());
    assert_eq!(harness.app().phase(), &Phase::Idle);

    let request = &server.received()[0];
    assert_eq!(request.body["messages"][0]["content"][0]["text"], "hello there");
    assert!(request.body["system"].as_str().is_some_and(|system| system.contains("qcli")));
    assert_eq!(request.body["tools"].as_array().map(Vec::len), Some(5));
    assert_eq!(request.header("api-key"), Some(MADE_UP));
    assert_eq!(request.body["model"], "test-model", "the model comes from the settings");

    let file = saved(&place);
    assert!(file.contains("Hello from the stand-in."));
    assert!(!file.contains(MADE_UP), "the key never reaches the saved conversation");
    assert!(!harness.screen().contains(MADE_UP), "nor the screen");
}

#[test]
fn the_conversation_comes_back_when_the_folder_is_opened_again() {
    let place = Place::new("resume");
    let server = FakeServer::start(vec![(200, says("First answer.")), (200, says("Second answer."))]);
    {
        let mut harness = harness(place.app(Some(&server.address)));
        say(&mut harness, "first question");
    }
    let mut harness = harness(place.app(Some(&server.address)));
    assert!(harness.screen().contains("first question"));
    assert!(harness.screen().contains("First answer."));
    say(&mut harness, "second question");
    let messages = server.received()[1].body["messages"].clone();
    assert_eq!(messages.as_array().map(Vec::len), Some(3), "the earlier turn goes out with the new one");
}

#[test]
fn a_read_is_done_without_asking_and_its_result_goes_back_to_the_model() {
    let place = Place::new("read");
    std::fs::write(place.work().join("notes.txt"), "the secret word is apricot\n").expect("write");
    let server = FakeServer::start(vec![
        (200, calls("c1", "read_file", &json!({"path": "notes.txt"}))),
        (200, says("The word is apricot.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "what is the word?");

    assert_eq!(server.received().len(), 2, "no question in between: reading does not ask");
    let result = &last_message(&server, 1)["content"][0];
    assert_eq!(result["type"], "tool_result");
    assert_eq!(result["tool_use_id"], "c1");
    assert!(result["content"].as_str().is_some_and(|text| text.contains("apricot")));
    let screen = harness.screen();
    assert!(screen.contains("read notes.txt"), "{screen}");
    assert!(screen.contains("The word is apricot."));
}

#[test]
fn an_edit_shows_its_diff_and_waits_until_allow_is_clicked() {
    let place = Place::new("edit");
    let file = place.work().join("greeting.txt");
    std::fs::write(&file, "hello world\n").expect("write");
    let server = FakeServer::start(vec![
        (200, calls("e1", "edit_file", &json!({"path": "greeting.txt", "old_text": "world", "new_text": "moon"}))),
        (200, says("Changed it.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "say hello to the moon");

    let screen = harness.screen();
    assert!(screen.contains("Change greeting.txt?"), "{screen}");
    assert!(screen.contains("hello moon") && screen.contains("hello world"), "both sides of the diff: {screen}");
    assert_eq!(std::fs::read_to_string(&file).expect("read"), "hello world\n", "nothing changes before the answer");
    assert_eq!(server.received().len(), 1);

    read_card(&mut harness);
    harness.click_text("Allow");
    assert_eq!(std::fs::read_to_string(&file).expect("read"), "hello moon\n");
    let result = &last_message(&server, 1)["content"][0];
    assert_eq!(result["is_error"], false);
    assert!(harness.screen().contains("Changed it."));
}

#[test]
fn a_declined_command_is_never_run_and_the_model_hears_why() {
    let place = Place::new("decline");
    let server = FakeServer::start(vec![
        (200, calls("r1", "run_command", &json!({"command": "printf ran > ran.txt"}))),
        (200, says("Understood.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "make a file");
    assert!(harness.screen().contains("Run this command?"));
    assert!(harness.screen().contains("printf ran > ran.txt"));

    read_card(&mut harness);
    harness.click_text("Decline");
    assert!(!place.work().join("ran.txt").exists(), "the command did not run");
    let result = &last_message(&server, 1)["content"][0];
    assert_eq!(result["content"], DECLINED);
    assert_eq!(result["is_error"], true);
}

#[test]
fn the_y_key_allows_a_command_and_its_output_goes_back() {
    let place = Place::new("run");
    let server = FakeServer::start(vec![
        (200, calls("r1", "run_command", &json!({"command": "printf 'from the command'"}))),
        (200, says("Done.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "run it");
    read_card(&mut harness);
    harness.press("y");
    let result = &last_message(&server, 1)["content"][0];
    assert!(result["content"].as_str().is_some_and(|text| text.contains("from the command")), "{result}");
    assert!(harness.screen().contains("from the command"), "the start of the output is shown");
}

/// A card that comes up while the person is still typing is not answered by the keys they go on
/// typing: "hey you" has a y in it, a space and Enter follow, and none of them may run a command
/// the person has not seen. Once they stop and the card has stood in front of them, `y` answers.
#[test]
fn keys_typed_while_a_card_comes_up_do_not_answer_it() {
    let place = Place::new("typing-on");
    let server = FakeServer::start(vec![
        (200, calls("r1", "run_command", &json!({"command": "printf ran > ran.txt"}))),
        (200, says("Done.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "make a file");
    assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "the card is up");

    harness.type_text("hey you").press("enter").press("space").press("n");
    assert!(!place.work().join("ran.txt").exists(), "nothing typed on ran the command");
    assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "nor answered the card either way");
    assert_eq!(server.received().len(), 1, "and nothing went back to the model");

    harness.advance(QUIET);
    harness.press("y");
    assert!(place.work().join("ran.txt").exists(), "a key pressed after the pause answers the card");
}

/// Enter and space on a card that has waited answer nothing: they are the keys that send a
/// message and separate its words, so only the card's own letters and a click on a button answer
/// it.
#[test]
fn enter_and_space_on_a_card_answer_nothing() {
    let place = Place::new("enter-answers-nothing");
    let server = FakeServer::start(vec![
        (200, calls("r1", "run_command", &json!({"command": "printf ran > ran.txt"}))),
        (200, says("Understood.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "make a file");
    read_card(&mut harness);
    harness.press("enter").press("space");
    assert!(!place.work().join("ran.txt").exists(), "neither ran the command");
    assert!(matches!(harness.app().phase(), Phase::Asking { .. }), "the card still waits");
    assert_eq!(server.received().len(), 1);

    read_card(&mut harness);
    harness.click_text("Decline");
    assert_eq!(last_message(&server, 1)["content"][0]["content"], DECLINED, "a click answers it");
}

/// Waits on a card until it takes answers, the way a person reads it before answering.
pub(crate) fn read_card(harness: &mut Harness<QCli>) {
    harness.advance(QUIET);
}

/// The row of a card that carries its two buttons, without the air around it.
fn button_row(screen: &str) -> String {
    screen.lines().find(|line| line.contains("Allow")).expect("the card's button row").trim().to_owned()
}

/// A card's two buttons name the keys the keymap binds their actions to, and the key that is
/// named is the key that answers: the test's keymap moves both, and the screen has to follow.
#[test]
fn a_cards_buttons_name_the_keys_the_keymap_binds_them_to() {
    let place = Place::new("card-keys");
    let server = FakeServer::start(vec![
        (200, calls("r1", "run_command", &json!({"command": "printf 'from the command'"}))),
        (200, says("Done.")),
    ]);
    let moved = KEYMAP.replace("allow = \"y\"", "allow = \"ctrl+y\"").replace("deny = \"n\"", "deny = \"ctrl+e\"");
    assert_ne!(moved, KEYMAP, "the test's keymap really moves both bindings");
    let mut harness = harness_with(place.app(Some(&server.address)), locales(), &moved);
    say(&mut harness, "run it");

    let screen = harness.screen();
    let row = button_row(&screen);
    assert!(row.contains("ctrl y   Allow"), "the moved allow key is on the button: {row}");
    assert!(row.contains("ctrl e   Decline"), "and the moved deny key on the other: {row}");

    read_card(&mut harness);
    harness.press("ctrl+y");
    let result = &last_message(&server, 1)["content"][0];
    assert!(
        result["content"].as_str().is_some_and(|text| text.contains("from the command")),
        "the key the button names is the key that answers: {result}"
    );

    // The same card under the keymap qcli ships: a different row, because the keymap is what
    // the row is made of.
    let own = Place::new("card-keys-own");
    let server = FakeServer::start(vec![
        (200, calls("r1", "run_command", &json!({"command": "printf 'from the command'"}))),
        (200, says("Done.")),
    ]);
    let mut shipped = super::tests::harness(own.app(Some(&server.address)));
    say(&mut shipped, "run it");
    let row = button_row(&shipped.screen());
    assert!(row.contains("y   Allow") && row.contains("n   Decline"), "the letters of qcli's own keymap: {row}");
    assert_ne!(row, button_row(&screen), "the card follows the keymap it is given");
}

/// The shipped keymap still puts a single letter on each button, read from the keymap like every
/// other key on the screen.
#[test]
fn the_cards_buttons_carry_the_single_letters_the_keymap_binds() {
    let place = Place::new("card-keys-letters");
    let server = FakeServer::start(vec![
        (200, calls("r1", "run_command", &json!({"command": "printf 'from the command'"}))),
        (200, says("Done.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "run it");
    let row = button_row(&harness.screen());
    assert!(row.contains("y   Allow"), "allow is bound to y: {row}");
    assert!(row.contains("n   Decline"), "deny is bound to n: {row}");
}

#[test]
fn esc_on_a_card_stops_the_turn_and_answers_every_waiting_call() {
    let place = Place::new("stop");
    let reply = crate::provider::fake::sse(&[
        json!({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "a", "name": "run_command", "input": {}}}),
        json!({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": "{\"command\": \"printf a\"}"}}),
        json!({"type": "content_block_stop", "index": 0}),
        json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "b", "name": "list_dir", "input": {}}}),
        json!({"type": "content_block_stop", "index": 1}),
        json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
        json!({"type": "message_stop"}),
    ]);
    let server = FakeServer::start(vec![(200, reply), (200, says("Fine."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "two things");
    assert!(matches!(harness.app().phase(), Phase::Asking { .. }));
    harness.press("esc");

    assert_eq!(harness.app().phase(), &Phase::Idle);
    assert_eq!(harness.app().notice(), Some(&Notice::Stopped));
    assert_eq!(server.received().len(), 1, "nothing more goes out after a stop");
    let last = harness.app().conversation().messages().last().cloned().expect("a message");
    let ids: Vec<_> = conversation::blocks(&last).map(|block| block["tool_use_id"].clone()).collect();
    assert_eq!(ids, ["a", "b"], "both calls are answered, so the next request is one the API accepts");
    assert!(harness.screen().contains("Stopped."));

    say(&mut harness, "go on");
    assert_eq!(server.received().len(), 2, "a stopped turn does not keep the next one from starting");
}

#[test]
fn a_refusal_is_shown_in_the_servers_words() {
    let place = Place::new("refused");
    let body = json!({"type": "error", "error": {"type": "authentication_error", "message": "invalid api key"}});
    let server = FakeServer::start(vec![(401, body.to_string())]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "hi");
    let screen = harness.screen();
    assert!(screen.contains("The provider did not answer"), "{screen}");
    assert!(screen.contains("invalid api key"));
    assert_eq!(harness.app().phase(), &Phase::Idle);
}

#[test]
fn the_folders_agents_file_goes_out_as_instructions() {
    let place = Place::new("agents");
    std::fs::write(place.work().join("AGENTS.md"), "Answer like a pirate.\n").expect("write");
    let server = FakeServer::start(vec![(200, says("Arr."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    assert!(harness.screen().contains("AGENTS.md is read as instructions."));
    say(&mut harness, "hi");
    let system = server.received()[0].body["system"].as_str().unwrap_or_default().to_owned();
    assert!(system.ends_with("Answer like a pirate."), "{system}");
}

#[test]
fn without_a_provider_the_form_opens_and_saving_it_writes_the_settings() {
    let place = Place::new("form");
    let mut harness = harness(place.app(None));
    assert!(harness.screen().contains("Provider"), "{}", harness.screen());
    assert!(harness.is_focused(form::FIRST));

    harness.click_text("Save");
    assert!(harness.screen().contains("This cannot be empty."), "the empty fields say so");
    assert!(harness.is_focused(form::FIRST), "and focus goes back to the first of them");

    harness.type_text("http://127.0.0.1:9/anthropic").press("tab").type_text("some-model").press("tab");
    harness.type_text(&place.base.join("missing-key").display().to_string());
    harness.click_text("Save");
    assert!(
        harness.screen().contains("cannot be read"),
        "a key file that is not there is caught: {}",
        harness.screen()
    );

    assert!(harness.is_focused("provider-key-file"));
    harness.press("ctrl+a").type_text(&place.base.join("key").display().to_string());
    harness.click_text("Save");
    assert!(!harness.screen().contains("Key file"), "the form closed: {}", harness.screen());
    let conf = std::fs::read_to_string(place.base.join("config/cli.conf")).expect("cli.conf is written");
    assert!(conf.contains("model = \"some-model\""), "{conf}");
    assert!(!conf.contains(MADE_UP), "only the key file's path is written, never the key");
}

/// The language qcli's own file names is the language of the first frame, over the ecosystem's,
/// and the words drawn in it are qcli's own: the screen is opened from the runtime `run()` starts,
/// and nothing here sets the language itself.
#[test]
fn the_language_the_settings_name_is_the_language_the_screen_is_drawn_in() {
    let place = Place::new("language");
    let folder = place.base.join("config");
    std::fs::write(folder.join("quvyta.conf"), "language = \"en\"\n").expect("the ecosystem's shared file");
    std::fs::write(folder.join("cli.conf"), "language = \"tr\"\n").expect("qcli's own file");

    let harness = opened(place.app(None), &folder);
    let screen = harness.screen();
    assert_eq!(harness.env().i18n().active(), "tr", "qcli's own file wins over the shared one");
    assert!(screen.contains("Bu klasörle ilgili"), "qcli's own words are drawn in Turkish:\n{screen}");
    assert!(screen.contains("çık"), "and so are the framework's:\n{screen}");
    assert!(!screen.contains("quit"), "and not in English:\n{screen}");
}

/// `run()` starts qcli as a member of the ecosystem, with its own language files: the language
/// another Quvyta application wrote into the shared file, and nothing in qcli's own, is the
/// language of qcli's own words on the first frame.
#[test]
fn run_starts_qcli_as_a_member_of_the_ecosystem() {
    let place = Place::new("run-member");
    let folder = place.base.join("config");
    std::fs::write(folder.join("quvyta.conf"), "language = \"tr\"\n").expect("the ecosystem's shared file");

    let harness = opened(place.app(None), &folder);
    let screen = harness.screen();
    assert_eq!(harness.env().i18n().active(), "tr", "the shared language is followed");
    assert!(screen.contains("Bu klasörle ilgili"), "in qcli's own words:\n{screen}");
}

/// Every value a person writes in qcli's own settings file is the value the screen is drawn with.
/// Both screens are the same program on the same machine against the same stand-in provider, and
/// every value in both files is written out, so nothing here depends on what this machine happens
/// to detect. The screen is opened from the runtime `run()` starts, with that folder, so the files
/// are read and applied before the first frame.
#[test]
fn every_value_in_qclis_own_file_is_the_value_the_screen_is_drawn_with() {
    let server = FakeServer::start(vec![]);
    let drawn = |name: &str, own: Option<&str>| {
        let place = Place::new(name);
        let folder = place.base.join("config");
        // The ecosystem's shared file with the values every Quvyta application starts from, and
        // qcli's own beside it: what a person writes in them.
        std::fs::write(folder.join("quvyta.conf"), "theme = \"monochrome\"\nicons = \"unicode\"\n").expect("shared");
        if let Some(own) = own {
            std::fs::write(folder.join("cli.conf"), own).expect("qcli's own");
        }

        let app = place.app(Some(&server.address));
        let harness = opened(app, &folder);
        // The name in the corner is drawn in the theme's accent colour, the writing field in the
        // icon mode's glyphs. Both are found by the words the active language file carries, so
        // they are asked for rather than written here.
        let name = harness.env().i18n().translate("app.name", &[]);
        let (x, y) = harness.find(&name).expect("the name in the corner");
        let screen = harness.screen();
        let placeholder = harness.env().i18n().translate("chat.placeholder", &[]);
        let field = screen.lines().find(|line| line.contains(&placeholder)).unwrap_or_default();
        (harness.fg(x as u16, y as u16), field.trim().to_owned())
    };

    let (amber, ascii) = drawn("settings-own", Some("theme = \"amber\"\nicons = \"ascii\"\n"));
    let (plain, unicode) = drawn("settings-shared", None);
    assert_ne!(amber, plain, "`theme` is the colour the name in the corner is drawn in");
    assert!(ascii.starts_with("> "), "`icons = \"ascii\"` writes `>`: {ascii:?}");
    assert!(unicode.starts_with("▌ ❯"), "and `icons = \"unicode\"` writes the pillar and the arrow: {unicode:?}");
}

/// What the form finds wrong is kept where the framework keeps it, under the name of the control
/// it belongs to, so the field shows its own message and focus lands on the first of them.
#[test]
fn the_forms_problems_are_named_after_the_controls_they_belong_to() {
    let provider = Provider::default();
    let mut form = ProviderForm::from_provider(&provider);
    assert!(form.check().is_none(), "an empty provider cannot be asked");

    let named: Vec<_> = form.errors().iter().map(|(field, _)| field).collect();
    assert_eq!(named, ["provider-address", "provider-model", "provider-key-file"], "in field order");
    assert_eq!(form.errors().first(), Some(form::FIRST), "focus goes to the first of them");
    let empty = form.errors().get(form::FIRST).expect("the address is empty, so it has a problem");
    assert!(!empty.is_empty(), "an empty field says so: {empty}");
    assert!(named.iter().all(|field| form.errors().get(field) == Some(empty)), "every empty field says the same");
    assert!(!form.errors().has("provider-key-header"), "the header has a default, so it is never a problem");

    // A key file that is not there is the key file's own problem, under its own name.
    let mut form = ProviderForm::from_provider(&Provider {
        address: "http://127.0.0.1:9".to_owned(),
        model: "some-model".to_owned(),
        key_file: "/nowhere/at/all/made-up-key".to_owned(),
        key_header: String::new(),
        ..Provider::default()
    });
    assert!(form.check().is_none());
    assert_eq!(form.errors().iter().map(|(field, _)| field).collect::<Vec<_>>(), ["provider-key-file"]);
    let problem = form.errors().get("provider-key-file").expect("the key file is the problem");
    assert_ne!(problem, empty, "the key file's own problem, not an empty field's: {problem}");
    assert!(form.errors().get("provider-address").is_none(), "the fields that are filled are left alone");
}

#[test]
fn ctrl_n_puts_the_conversation_aside_and_starts_a_new_one() {
    let place = Place::new("new");
    let server = FakeServer::start(vec![(200, says("Remember me."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "keep this");
    harness.press("ctrl+n");
    assert!(harness.app().conversation().is_empty());
    assert!(harness.screen().contains("Ask anything about this folder"));
    let kept = std::fs::read_dir(place.base.join("conversations")).expect("folder").count();
    assert_eq!(kept, 1, "the old conversation is kept under another name");
}

#[test]
fn the_end_of_the_conversation_stays_in_view_as_it_grows() {
    let place = Place::new("follow");
    let long: String = (1..=60).map(|n| format!("Line number {n} of a long answer.\n\n")).collect();
    let server = FakeServer::start(vec![(200, says(&long))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "tell me a lot");
    let screen = harness.screen();
    assert!(screen.contains("Line number 60"), "the last line is on screen: {screen}");
    assert!(screen.contains("Write a message"), "and the writing field under it");
}

#[test]
fn a_person_scrolled_up_to_read_is_not_pulled_down_while_the_reply_streams() {
    let place = Place::new("read-back");
    let long: String = (1..=60).map(|n| format!("Line number {n} of a long answer.\n\n")).collect();
    // A long answer that ends by asking to run a command; the reply after the command streams
    // in while the person reads further up.
    let first = crate::provider::fake::sse(&[
        json!({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}),
        json!({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": long}}),
        json!({"type": "content_block_stop", "index": 0}),
        json!({"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "id": "r1", "name": "run_command", "input": {}}}),
        json!({"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": "{\"command\": \"printf done\"}"}}),
        json!({"type": "content_block_stop", "index": 1}),
        json!({"type": "message_delta", "delta": {"stop_reason": "tool_use"}}),
        json!({"type": "message_stop"}),
    ]);
    let more: String = (1..=20).map(|n| format!("More text number {n}.\n\n")).collect();
    let server = FakeServer::start(vec![(200, first), (200, says(&more)), (200, says("Back at the end."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "tell me a lot");
    assert!(harness.screen().contains("Run this command?"), "the card is in view at the end");

    // The wheel over the conversation, the way a person scrolls up to read.
    for _ in 0..10 {
        harness.mouse(MouseKind::ScrollUp, 40, 10);
    }
    let reading = harness.screen();
    assert!(!reading.contains("Run this command?"), "scrolled up: {reading}");

    // Allowing the command from the keyboard lets the next reply stream in while the person reads.
    read_card(&mut harness);
    harness.press("y");
    assert_eq!(server.received().len(), 2, "the reply after the command came");
    // The lines being read stay where they are. The writing field comes back under the
    // conversation once the card is answered and takes the bottom row, nothing more.
    // Only the words: the scrollbar at the right edge rightly changes as the content grows.
    let lines = |screen: &str| {
        screen
            .lines()
            .filter_map(|line| line.split(" of a long").next().filter(|_| line.contains("Line number")))
            .map(|line| line.trim().to_owned())
            .collect::<Vec<_>>()
    };
    let (before, after) = (lines(&reading), lines(&harness.screen()));
    assert!(!after.is_empty() && before.starts_with(&after), "the view moved: {}", harness.screen());
    assert!(before.len() - after.len() <= 1);
    assert!(!harness.screen().contains("More text number 20"));
    assert!(harness.screen().contains("Write a message"), "the writing field stays under the conversation");

    // Sending the next message opens the conversation at its end again.
    say(&mut harness, "and now?");
    assert!(harness.screen().contains("Back at the end."), "{}", harness.screen());
}

/// A real model reads a real file through the whole chat. Ignored unless asked for by name, like
/// the provider's live tries; it takes the provider from the same `QCLI_LIVE_*` variables.
#[test]
#[ignore = "talks to a real provider; run by hand"]
fn live_a_real_model_reads_a_file_it_is_asked_about() {
    let place = Place::new("live");
    std::fs::write(place.work().join("pantry.txt"), "The jar on the top shelf holds cardamom.\n").expect("write");
    let variable = |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set"));
    let mut settings = config::load_in(&place.base.join("config"));
    Provider {
        address: variable("QCLI_LIVE_ADDRESS"),
        model: variable("QCLI_LIVE_MODEL"),
        key_file: variable("QCLI_LIVE_KEY_FILE"),
        key_header: std::env::var("QCLI_LIVE_HEADER").unwrap_or_default(),
        source: config::Source::Settings,
    }
    .write(&mut settings);
    let app = QCli::new(Workspace::new(&place.work()).expect("workspace"), place.store(), settings);
    let mut harness = harness(app);
    say(&mut harness, "Read pantry.txt and tell me in one short sentence what the jar on the top shelf holds.");
    println!("{}", harness.screen());
    assert_eq!(harness.app().notice(), None, "no error");
    let messages = harness.app().conversation().messages();
    let read = messages.iter().flat_map(conversation::blocks).any(|block| block["name"] == "read_file");
    assert!(read, "the model used the read action");
    let answer = messages.last().map(ToString::to_string).unwrap_or_default().to_lowercase();
    assert!(answer.contains("cardamom"), "the answer comes from the file: {answer}");
}

/// The folders of the update notice under the test's own place, never the person's.
fn update_folders(place: &Place) -> config::UpdateFolders {
    config::UpdateFolders { config: place.base.join("config"), state: place.base.join("state") }
}

#[test]
fn qcli_asks_once_at_start_for_a_newer_version_of_itself() {
    let place = Place::new("updates");
    let harness = harness(place.app(None).update_notice(Some(update_folders(&place))));
    let asked = harness.update_checks().to_vec();
    assert_eq!(asked.len(), 1, "one question at start");
    assert_eq!((asked[0].package(), asked[0].current()), ("quvyta-cli", env!("CARGO_PKG_VERSION")));
}

/// The alpha after the one running: `0.1.0-alpha.2` gives `0.1.0-alpha.3`.
fn next_alpha() -> String {
    let current = env!("CARGO_PKG_VERSION");
    let (base, number) = current.rsplit_once('.').expect("an alpha version");
    let number: u32 = number.parse().expect("the alpha's number");
    format!("{base}.{}", number + 1)
}

#[test]
fn a_person_on_an_alpha_is_told_in_the_corner_when_the_next_alpha_is_out() {
    let place = Place::new("updates-alpha");
    let mut harness = harness(place.app(None).update_notice(Some(update_folders(&place))));
    let next = next_alpha();
    harness.set_latest_version(Some(&next));
    let screen = harness.screen();
    assert!(screen.contains(&next), "the next alpha is named in the corner:\n{screen}");
}

#[test]
fn a_person_on_an_alpha_is_not_told_about_the_alpha_already_running() {
    let place = Place::new("updates-same");
    let mut harness = harness(place.app(None).update_notice(Some(update_folders(&place))));
    harness.set_latest_version(Some(env!("CARGO_PKG_VERSION")));
    assert!(!harness.screen().contains("is out"), "nothing newer, nothing said");
}

#[test]
fn with_the_ecosystems_switch_off_nothing_is_asked() {
    let place = Place::new("updates-off");
    std::fs::write(place.base.join("config/quvyta.conf"), "update-notice = false\n")
        .expect("the ecosystem's shared file");
    let mut harness = harness(place.app(None).update_notice(Some(update_folders(&place))));
    assert!(harness.update_checks().is_empty(), "nothing is asked");
    harness.set_latest_version(Some("9.4.7"));
    assert!(!harness.screen().contains("is out"));
}

/// The `n` key declines a card, the way the person's other hand answers it.
#[test]
fn the_n_key_declines_a_card_and_nothing_runs() {
    let place = Place::new("n-declines");
    let server = FakeServer::start(vec![
        (200, calls("r1", "run_command", &json!({"command": "printf ran > ran.txt"}))),
        (200, says("Understood.")),
    ]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "make a file");
    read_card(&mut harness);
    harness.press("n");
    assert!(!place.work().join("ran.txt").exists(), "the command did not run");
    assert_eq!(last_message(&server, 1)["content"][0]["content"], DECLINED);
}

/// Cancel on the provider form closes it and writes nothing, whatever was typed into it.
#[test]
fn cancel_on_the_provider_form_closes_it_and_writes_nothing() {
    let place = Place::new("form-cancel");
    let mut harness = harness(place.app(Some("https://models.example.test")));
    let settings = place.base().join("config").join("cli.conf");
    let before = std::fs::read_to_string(&settings).unwrap_or_default();
    harness.press("ctrl+o");
    harness.type_text("-changed");
    harness.click_text("Cancel");
    assert!(harness.app().form.is_none(), "the form is closed");
    assert_eq!(std::fs::read_to_string(&settings).unwrap_or_default(), before, "and nothing was written");
    assert!(harness.screen().contains("Write a message"), "the chat is back: {}", harness.screen());
}

/// A conversation that cannot be saved is said on screen; the chat goes on.
#[test]
fn a_conversation_that_cannot_be_saved_is_said_and_the_chat_goes_on() {
    let place = Place::new("unsaved");
    // A file where the conversations folder would be: nothing can be written under it.
    std::fs::write(place.base().join("conversations"), "in the way").expect("a file in the way");
    let server = FakeServer::start(vec![(200, says("Still here."))]);
    let mut harness = harness(place.app(Some(&server.address)));
    say(&mut harness, "hello");
    assert!(matches!(harness.app().notice(), Some(Notice::Unsaved(_))), "{:?}", harness.app().notice());
    assert!(harness.screen().contains("could not be saved"), "the person is told: {}", harness.screen());
    assert!(harness.screen().contains("Still here."), "and the reply still came: {}", harness.screen());
}