mindfork 0.11.0

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
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
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
//! UI ↔ orchestrator exchange contract: commands and events.
//! Unidirectional data flow, see spec §4.4.

use uuid::Uuid;

use crate::entities::attachment::AttachmentInfo;
use crate::entities::chat::{ChatSummary, FeedView};
use crate::entities::message::{Message, MessageRole};
use crate::entities::message_image::ImageInfo;
use crate::entities::profile::{CharacterNames, Profile, ProfileSummary};
pub use crate::features::chat_search::FeedFocus;
use crate::features::chat_search_sort::SortMode;
pub use crate::features::file_command::FileProgress;
pub use crate::features::image_command::ImageProgress;
use crate::features::profiles::ProfileEdit;
pub use crate::features::rag_ingest::RagProgress;
pub use crate::features::tools::confirm::ToolDecision;
use crate::shared::api::FinishReason;
use crate::shared::config::AppConfig;
pub use crate::shared::server::{ServerStatus, ServerStatuses};

/// Raw pixels taken off the system clipboard (`arboard::ImageData`): RGBA8, row-major.
///
/// Deliberately un-encoded at this point. Turning a screenshot into a png costs tens of
/// milliseconds, and the clipboard is read on the **input thread** — so the encode is left
/// to the orchestrator's blocking pool, where every other image already goes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardImage {
    pub width: u32,
    pub height: u32,
    pub rgba: Vec<u8>,
}

/// Command from UI to orchestrator.
#[derive(Debug, Clone)]
pub enum AppCommand {
    /// Send the user's message to the active chat and start generation.
    SendMessage(String),
    /// The user's answer to an [`AppEvent::ToolConfirmRequest`]. Routed into the
    /// running generation task; a reply whose `generation_id` is not the turn in
    /// flight is dropped (spec §9.8).
    ConfirmTool {
        generation_id: Uuid,
        call_id: String,
        decision: ToolDecision,
    },
    /// Save the input box draft in the active chat (unsaved text). UI sends this
    /// on every input change; the orchestrator writes it to the chat file with a debounce.
    /// See spec §11.7.
    SetDraft(String),
    /// Save the feed's collapse state on the active chat (`Ctrl+T` — "thoughts",
    /// `Ctrl+O` — tool calls). Like [`AppCommand::SetDraft`]: written with the
    /// save debounce, `modified_at` untouched — a view toggle must not bump the
    /// chat up the list. See spec §11.3, docs/feed-collapse.md.
    SetFeedView(FeedView),
    /// Store whether the chat list shows this chat's sub-agent transcripts
    /// (`Ctrl+O` in the list, `/subagents` in the chat). Addressed by id — the
    /// list toggles any row's chat, not only the active one; a transcript's id
    /// resolves to its parent, like [`AppCommand::SetFeedView`]'s sharing.
    /// Written with the save debounce, `modified_at` untouched — a view toggle
    /// must not bump the chat up the list. See spec §11.2.
    SetChildrenExpanded { id: Uuid, expanded: bool },
    /// Stop a sub-agent run that is out in the **background** (`/subagents
    /// stop [n]`, spec §9.3.2): `id` is the run's own id. The run's token is
    /// cancelled and it lands as `cancelled` through the same route every
    /// end takes; an id that names no running background run is ignored.
    StopSubagentRun { id: Uuid },
    /// Stop one of the app's own silent tasks (`F6` on its row of the tasks
    /// screen, spec §11.10): the slot's token is cancelled and the task
    /// lands as *cancelled* through the outcome route every end takes —
    /// the failure streak untouched, the window skipped; a kind with no
    /// task running is ignored (docs/research/stop-silent-task.md §3).
    StopBackgroundTask { kind: BackgroundKind },
    /// Regenerate the last assistant reply: delete everything after the last
    /// user message and restart generation from the same request.
    RegenerateLast,
    /// Resume the last interrupted assistant reply in place (`/continue`,
    /// spec §6.4): prefill the trailing partial and append what arrives into
    /// the same message; a tool-result tail resumes the agentic loop instead.
    ContinueLast,
    /// Delete the last exchange (the assistant's reply together with the user
    /// message that triggered it); the user's text is restored into the input box
    /// (`RestoreInput`).
    DeleteLastExchange,
    /// Cancel the current generation.
    Cancel,
    /// Write a message on the user's behalf (impersonation, `Ctrl+U`). `seed` —
    /// text already typed into the field (the model continues it; empty — writes from scratch).
    /// The result streams via `Impersonation*` events. See spec §11.8.
    Impersonate { seed: String },
    /// Cancel the current impersonation.
    CancelImpersonation,
    /// Create a new chat from a profile (by `id`; `None` — the default profile).
    NewChat { profile_id: Option<Uuid> },
    /// Make a chat active (load it into the feed).
    SwitchChat(Uuid),
    /// Make a chat active **and put the feed on one of its messages** (a jump
    /// from a search hit). Same activation as `SwitchChat`, plus a focus carried
    /// through `ChatActivated`; a message the feed doesn't show (a `Tool`/
    /// `System` one) simply lands at the tail. See docs/history/chat-search-stage2.md §3.
    OpenChatAt {
        chat: Uuid,
        message: Uuid,
        /// The query the hit came from — its matches are highlighted inside the
        /// focused message (fork S3(b)). Empty when there is nothing to
        /// highlight.
        query: String,
    },
    /// Make a chat active and put the feed on its **first message matching
    /// `query`** (`Enter` in the chat list's content mode). The orchestrator
    /// resolves the message: it owns both the index and the chat, so only it
    /// can order the matches by real chat position. A chat whose match cannot
    /// be resolved simply opens at the tail, exactly like a plain switch.
    OpenChatAtFirstMatch { chat: Uuid, query: String },
    /// Rename a chat.
    RenameChat { id: Uuid, title: String },
    /// Auto-title a chat: the model reads the conversation (or part of it) and comes up
    /// with a title. The request runs as a background task; the result is a `ChatRenamed`
    /// event.
    AutoRenameChat(Uuid),
    /// Clone a chat (a copy of the messages and settings).
    CloneChat(Uuid),
    /// Copy the whole chat conversation to the clipboard. The orchestrator builds the
    /// text (it owns `Chat`) and emits `CopyToClipboard`; writing to the clipboard is a
    /// UI-layer concern.
    CopyChat(Uuid),
    /// Write the chat's conversation to a file (`/export`). Unlike
    /// [`AppCommand::CopyChat`] the orchestrator finishes the job itself: it owns
    /// `Chat` *and* the disk, and the answer is a path, not content. Reports
    /// through `Notice`/`Error`. See docs/history/chat-export-file.md.
    ExportChat {
        id: Uuid,
        format: crate::features::export_command::ExportFormat,
        path: Option<String>,
    },
    /// Soft-delete a chat.
    DeleteChat(Uuid),
    /// Full-text search over chat **content** (the chat list's content mode,
    /// `Ctrl+F`). The argument is the **raw** user query: escaping it into a
    /// valid FTS5 query is the orchestrator's job, so that rule lives in one
    /// place (`features::chat_search::to_fts_query`; FSD — see
    /// docs/research/chat-content-search.md §4, §7a). The result is a
    /// [`AppEvent::ChatSearchResults`] event.
    SearchChats(String),
    /// Full-text search over chat content answered at **message** level (the
    /// chat list's `Ctrl+G`) — the query is raw, escaped by the orchestrator
    /// exactly like [`AppCommand::SearchChats`]. The result is a
    /// [`AppEvent::MessageSearchResults`] event. See docs/history/chat-search-stage2.md.
    SearchMessages { query: String, sort: SortMode },
    /// Create a new profile (the UI section is M8; the command is needed for
    /// operations/tests).
    CreateProfile {
        name: String,
        system_message: String,
    },
    /// Soft-delete a profile with a cascade onto its chats (notes/RAG are excluded).
    DeleteProfile(Uuid),
    /// Replace the whole config (settings screen). The orchestrator saves it,
    /// restarts the server/registry if needed, and re-emits it. See spec §11.6.
    /// `Box` — `AppConfig` is large, don't bloat the enum.
    UpdateConfig(Box<AppConfig>),
    /// Apply profile edits (settings screen). `Box` — `ProfileEdit` carries
    /// large fields (the system message).
    UpdateProfile { id: Uuid, edit: Box<ProfileEdit> },
    /// Index a file or directory into the active profile's knowledge base (RAG)
    /// (the `/rag add <path> [-r]` command). Runs as a background task; progress
    /// arrives via `RagProgress` events. See spec §9.3.
    RagAdd { path: String, recursive: bool },
    /// Remove a file or directory (and everything under it) from the active
    /// profile's knowledge base (the `/rag remove <path>` command). The result is a
    /// `RagProgress` event.
    RagDelete { path: String },
    /// Show the active profile's knowledge-base sources (the `/rag list` command).
    /// The result is a `RagProgress::Listed` event.
    RagList,
    /// Reindex the active profile's knowledge base (the `/rag rebuild` command):
    /// re-chunk and re-embed the stored sources. Runs as a background task;
    /// progress — via `RagProgress` events. See spec §9.3.
    RagRebuild,
    /// Re-embed every stored vector with the current embedding model
    /// (`/reindex`). Unlike `RagRebuild` this is **DB-global** and re-embeds
    /// **in place**: no re-chunking, no source text needed, so it also repairs
    /// legacy rows whose file is gone and attachment indexes that would
    /// otherwise only come back on re-attach. See
    /// docs/research/embedding-model-change-reindex.md §8.1.
    Reindex,
    /// Compress the older part of the active chat into a rolling summary
    /// (`/compact`, spec §6.7). Refused when the master switch is off.
    Compact,
    /// Attach a file to the active chat (the `/file attach <path>` command).
    /// Reading/extracting the text runs as a background task; the result arrives
    /// as a `FileProgress` event. See docs/file-attachments.md, spec §9.7.
    FileAttach { path: String },
    /// Remove an attachment from the active chat by display name, path, or `#N`
    /// (the `/file remove <target>` command).
    FileRemove { target: String },
    /// Show the active chat's attachments and stored files (the `/file list` command). The result
    /// is a `FileProgress::Listed` event.
    FileList,
    /// Open one of the active chat's files in the desktop environment (the
    /// `/file open <name|#N>` command, fork F9 of docs/history/sandbox-file-exchange.md).
    /// The handle is the one `/file list` shows; the launch runs on the blocking
    /// pool and the outcome arrives as a `FileProgress` event.
    FileOpen { target: String },
    /// Open the active chat's stored-files folder (the `/file folder` command).
    FileFolder,
    /// Attach a code project to the active chat (the `/project attach <dir>`
    /// command). The directory is checked and canonicalized synchronously — it
    /// is a `stat`, not a read — and the result arrives as a `ProjectProgress`
    /// event. See docs/history/code-workspace.md, spec §9.12.
    ProjectAttach { path: String },
    /// Detach the active chat's code project (the `/project detach` command).
    ProjectDetach,
    /// Report the active chat's code project (the `/project status` command).
    ProjectStatus,
    /// Build what the assistant changed in the active chat's project, for the
    /// changes screen (`F4` / `/changes`, spec §9.12). The result arrives as a
    /// `WorkspaceChanges` event.
    OpenChanges,
    /// Put one file back to how it was before the assistant first touched it,
    /// then re-send the change set. Confirmed on the screen, so this is the
    /// decision already taken.
    RevertWorkspaceFile { path: String },
    /// Set, show or clear one of the project's build/run/test command slots
    /// (spec §9.12). The result arrives as a `ProjectProgress` event.
    ProjectSlot {
        slot: crate::entities::workspace::CommandSlot,
        action: crate::features::project_command::SlotAction,
    },
    /// Stage an image for the next message (the `/image attach <path>` command).
    /// Reading, decoding and downscaling run as a background task; the result arrives as
    /// an `ImageProgress` event. See spec §9.10.
    ImageAttach { path: String },
    /// Unstage an image by display name, path, or `#N` (`/image remove <target>`).
    ImageRemove { target: String },
    /// Show what is staged for the next message (`/image list`). The result is an
    /// `ImageProgress::Listed` event.
    ImageList,
    /// Stage an image read off the system clipboard (`Ctrl+V`, `/image paste`).
    ///
    /// Carries the pixels rather than a request to read them: the `arboard` client lives
    /// in `runtime` (a UI-layer side effect, like `CopyToClipboard`), so by the time the
    /// orchestrator is involved the clipboard has already been consulted. `Box` — an
    /// uncompressed screenshot is megabytes, and every other variant would pay for it.
    ImagePaste(Box<ClipboardImage>),
    /// Speak the active chat's messages (the `/tts [N|all]` command). The orchestrator
    /// (the owner of `Chat`) takes a **snapshot** of the conversation at command time and
    /// starts a background synthesis/playback task. A new command interrupts the current
    /// playback. See spec §11.9, docs/research/tts.md §7.
    Tts(crate::features::tts_command::TtsScope),
    /// Stop speech playback and clear the queue (the `/tts stop` command).
    TtsStop,
    /// Pause playback, keeping the queue (the `/tts pause` command).
    TtsPause,
    /// Resume paused playback (the `/tts resume` command).
    TtsResume,
    /// Request a snapshot of everything the app is doing in the background —
    /// the tasks screen's rows (`F7` / `/tasks`, spec §11.10). The
    /// orchestrator answers with an [`AppEvent::TaskList`]; it also emits one
    /// unasked whenever the rows change, so this is the screen's opening
    /// request and its refresh after coming back, nothing more.
    RequestTasks,
    /// Request a snapshot of the active profile's "self-model" (for the viewer screen,
    /// `F3`). The orchestrator (the owner of `Storage`) responds with a `SelfModelView`
    /// event.
    RequestSelfModel,
    /// Apply a manual edit to the active profile's "self-model" (the `F3` UI editor).
    /// The orchestrator applies it, saves, and re-emits the updated `SelfModelView`.
    UpdateSelfModel(crate::entities::self_model::SelfModelEdit),
    /// Confirm a changed tool catalog for an MCP server (TOFU
    /// reconfirmation from settings): register the held-back tools
    /// and persist the new pin. The argument is the server id. See spec §9.6.
    ConfirmMcpCatalog(String),
    /// Store a secret entered in settings — a cloud provider's API key, the
    /// backup password, or an MCP server's environment value. The orchestrator
    /// encrypts it with the machine key and puts it into `config.api_keys`:
    /// plaintext lives only along this path and in the consumer (the HTTP client,
    /// the archive, the child process), never on disk. An empty `value` deletes
    /// it. Separate from `UpdateConfig` precisely so the secret never travels in
    /// a config snapshot. See `shared::secrets`, docs/research/api-key-storage.md.
    SetSecret {
        key: crate::shared::secrets::SecretKey,
        value: String,
    },
    /// Restart one MCP server (Enter on its row in settings when there is no
    /// catalog to confirm). An action, not a config edit: an identical config is
    /// not re-applied (`McpManager::is_current`), so a server that exhausted its
    /// restart budget has no other way back. See spec §9.6.
    ReconnectMcpServer(String),
    /// Import MCP servers from an ecosystem `mcpServers` JSON file (the argument
    /// is the path). Parsed by the orchestrator, not the screen: it is the sole
    /// writer of `settings.json` and the only layer allowed to touch the literal
    /// secrets such a file carries. See docs/history/mcp-server-editor.md §9.
    ImportMcpServers(String),
    /// Ask the provider for its model catalogue, for one settings row
    /// ([docs/research/model-picker.md](../../docs/research/model-picker.md)).
    ///
    /// **The only thing the UI asks the network for.** Every other fact about an
    /// engine reaches the screens as an event they never requested
    /// ([`AppEvent::EngineSlots`], [`AppEvent::EngineSamplingFields`]); this one
    /// is a keypress, and it is sent only when the user opens the picker (fork
    /// F4) — the app makes no catalogue request nobody asked for. The answer is
    /// [`AppEvent::ModelCatalogue`]; no key travels on this channel, since the
    /// orchestrator resolves the slot's own from the config it already holds.
    ListModels(crate::shared::api::catalogue::ModelSlot),
    /// Shut down (the orchestrator stops).
    Quit,
}

impl AppCommand {
    /// Does this command **work on the open conversation** — change what it
    /// stores, or start a turn in it?
    ///
    /// The one consumer is the `Esc` back-stack (`app::runtime::Back`): a way
    /// back exists for someone who is *looking* at the chat they drilled into,
    /// and stops making sense the moment they start working in it. See the
    /// clearing funnel in `app::runtime::dispatch`.
    ///
    /// Deliberately an **exhaustive match** rather than a list of the few
    /// interesting variants: a new command then cannot join the enum without
    /// someone deciding which side it falls on — the compiler asks. The rule is
    /// narrow on purpose. Reading (`FileList`, `CopyChat`, `Tts*`), looking
    /// (`SetFeedView`), typing without sending (`SetDraft`) and staging for the
    /// *next* message (`Image*` — turn-scoped, never stored) all leave the way
    /// back alone; a chat switch is not here at all, because activation already
    /// has its own funnel.
    pub fn works_on_the_open_chat(&self) -> bool {
        match self {
            // Starts a turn in this conversation.
            AppCommand::SendMessage(_)
            | AppCommand::RegenerateLast
            | AppCommand::ContinueLast
            | AppCommand::Impersonate { .. }
            // Changes what the conversation stores.
            | AppCommand::DeleteLastExchange
            // Ends a background run: its record lands as cancelled.
            | AppCommand::StopSubagentRun { .. }
            | AppCommand::Compact
            | AppCommand::FileAttach { .. }
            | AppCommand::FileRemove { .. }
            // Attaching or detaching a project changes what this chat's turns
            // can reach, and is stored in the chat file.
            | AppCommand::ProjectAttach { .. }
            | AppCommand::ProjectDetach => true,

            // Setting or clearing a command slot writes to the chat file, and is
            // work *in* this conversation; asking what a slot holds only reads.
            AppCommand::ProjectSlot { action, .. } => !matches!(
                action,
                crate::features::project_command::SlotAction::Show
            ),
            AppCommand::RevertWorkspaceFile { .. } => true,

            // Reading the conversation out to a file changes nothing in it —
            // the same side as `CopyChat`.
            AppCommand::ExportChat { .. }
            // Reporting the project reads it and changes nothing, the side
            // `FileList` is on — and so is looking at what changed. Reverting a
            // file writes to the user's project, but not to the conversation:
            // it is work *on the chat's project*, which is the same side as
            // setting a command slot.
            | AppCommand::ProjectStatus
            | AppCommand::OpenChanges
            | AppCommand::ConfirmTool { .. }
            | AppCommand::SetDraft(_)
            | AppCommand::SetFeedView(_)
            | AppCommand::SetChildrenExpanded { .. }
            // Stops one of the app's own tasks: nothing in the conversation
            // changes (docs/research/stop-silent-task.md §3.3).
            | AppCommand::StopBackgroundTask { .. }
            | AppCommand::Cancel
            | AppCommand::CancelImpersonation
            | AppCommand::NewChat { .. }
            | AppCommand::SwitchChat(_)
            | AppCommand::OpenChatAt { .. }
            | AppCommand::OpenChatAtFirstMatch { .. }
            | AppCommand::RenameChat { .. }
            | AppCommand::AutoRenameChat(_)
            | AppCommand::CloneChat(_)
            | AppCommand::CopyChat(_)
            | AppCommand::DeleteChat(_)
            | AppCommand::SearchChats(_)
            | AppCommand::SearchMessages { .. }
            | AppCommand::CreateProfile { .. }
            | AppCommand::DeleteProfile(_)
            | AppCommand::UpdateConfig(_)
            // Asking a provider what models it serves is a settings-screen
            // question about an engine, not work in any conversation.
            | AppCommand::ListModels(_)
            | AppCommand::UpdateProfile { .. }
            | AppCommand::RagAdd { .. }
            | AppCommand::RagDelete { .. }
            | AppCommand::RagList
            | AppCommand::RagRebuild
            | AppCommand::Reindex
            | AppCommand::FileList
            | AppCommand::FileOpen { .. }
            | AppCommand::FileFolder
            | AppCommand::ImageAttach { .. }
            | AppCommand::ImageRemove { .. }
            | AppCommand::ImageList
            | AppCommand::ImagePaste(_)
            | AppCommand::Tts(_)
            | AppCommand::TtsStop
            | AppCommand::TtsPause
            | AppCommand::TtsResume
            | AppCommand::RequestTasks
            | AppCommand::RequestSelfModel
            | AppCommand::UpdateSelfModel(_)
            | AppCommand::ConfirmMcpCatalog(_)
            | AppCommand::SetSecret { .. }
            | AppCommand::ReconnectMcpServer(_)
            | AppCommand::ImportMcpServers(_)
            | AppCommand::Quit => false,
        }
    }
}

/// What the chat screen needs to know about a sub-agent transcript it shows
/// (spec §11.2, docs/research/subagent-chats.md §3.8): whose it is, and the
/// persona to draw as its first bubble.
#[derive(Debug, Clone, PartialEq)]
pub struct ChildView {
    /// The chat whose call made the transcript.
    pub parent: Uuid,
    pub parent_title: String,
    /// The sub-agent's system message — the persona the parent composed.
    pub system_message: String,
}

/// Event from the orchestrator to UI. This is the only way UI updates its read-only
/// The generation running on the conversation being activated
/// ([`AppEvent::ChatActivated::live_turn`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveTurn {
    /// The turn's generation id — what the sub-agent chip is keyed on.
    pub turn: Uuid,
    /// The stream this conversation's feed accepts: the turn's own on its
    /// chat, the sub-agent's own on its transcript (docs/history/subagent-live.md §8).
    pub stream: Uuid,
    /// The round in progress so far — text and thoughts — so a feed opened
    /// mid-round starts with what has already streamed.
    pub partial: Option<LivePartial>,
    /// The round in progress **continues** the feed's last assistant bubble
    /// (`/continue` before its first tool round): the partial is appended
    /// there, with no separator, instead of opening a bubble of its own.
    pub continues: bool,
    /// Which side of the conversation the round in progress streams into:
    /// `Assistant` for a turn's own chat and a sub-agent's transcript; a
    /// dialogue's line carries its speaker's side (spec §9.13 — participant
    /// `b`'s lines are the transcript's `User` role).
    pub role: MessageRole,
    /// The conversation is the transcript of a run out in the **background**
    /// (spec §9.3.2, docs/research/background-subagents.md §4.5): its stream
    /// is no turn's, so on it `Esc` goes back instead of cancelling and `F6`
    /// stops the run. `false` on a turn's own chat and on a turn child's
    /// transcript, where `Esc` cancels the turn.
    pub background: bool,
}

/// A round in progress (see [`LiveTurn::partial`]): its text and thoughts
/// so far, and the tool calls it has opened — running or already answered —
/// which are not in any filed message yet.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LivePartial {
    pub text: String,
    pub thoughts: String,
    pub tools: Vec<LiveTool>,
}

/// One tool call of a round in progress (see [`LivePartial::tools`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveTool {
    pub call_id: String,
    pub name: String,
    pub arguments: String,
    /// `None` while the call is running.
    pub result: Option<(String, usize)>,
}

/// Which kind of nested run the chip describes — the screen words each in the
/// interface language ([`SubagentProgress`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunProgressKind {
    /// A `call_subagent` run: `round` is its tool round, `tool` the tool it
    /// is inside, if any (spec §9.3.2).
    Subagent,
    /// A dialogue's participant line being written: `round` is the line
    /// number (spec §9.13).
    DialogueLine,
    /// A dialogue's director checkpoint — the scene is being judged.
    DialogueDirector,
}

/// One nested run's position, for the status-bar chip
/// ([`AppEvent::SubagentProgress`]): the persona's name (the `name` argument,
/// else the run's title), the round/line it is on, and the tool it is inside,
/// if any. Raw data — the screen words it in the interface language.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubagentProgress {
    pub name: String,
    pub round: u32,
    pub tool: Option<String>,
    pub kind: RunProgressKind,
}

/// One sub-agent or dialogue run as the tasks screen lists it
/// ([`AppEvent::TaskList`], spec §11.10): a projection of the orchestrator's
/// mirror while the run is in flight, and of the record on its parent chat
/// once it has landed — nothing is stored for the screen's sake
/// (docs/research/tasks-screen.md R4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskRun {
    /// The run's id — what `Enter` opens and `F6` stops.
    pub id: Uuid,
    pub kind: crate::entities::subagent::RunKind,
    pub title: String,
    /// The chat whose exchange started it, and its title for the row.
    pub parent: Uuid,
    pub parent_title: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
    /// How the run ended; `None` while it runs — or, with `running` false,
    /// a run that never reported (the chat list's *interrupted* /
    /// *unfinished* rows, spec §11.2).
    pub outcome: Option<crate::entities::subagent::RunOutcome>,
    /// The run is in flight right now — the orchestrator holds its mirror.
    pub running: bool,
    /// Out in the background (a seat of its own, spec §9.3.2) rather than a
    /// child of the turn in flight. Only a running background run can be
    /// stopped from the screen: `AppCommand::StopSubagentRun` names seats.
    pub background: bool,
    /// Completion tokens so far (the run's own count while it runs).
    pub tokens: u64,
    /// Where a running run stands — its latest [`SubagentProgress`] step,
    /// stored on the mirror by the orchestrator. `None` before its first
    /// round, and always on a landed row.
    pub position: Option<SubagentProgress>,
}

impl TaskRun {
    /// A background sub-agent run out right now, under a chat called
    /// "Plans", started ten minutes ago with no position yet. Shared by the
    /// tasks screen's and the runtime's tests — each keeping a copy of this
    /// literal is the sliding self-duplication the gate measures
    /// (docs/lessons.md §2); tests mutate the fields they are about.
    #[cfg(test)]
    pub fn fixture(title: &str) -> Self {
        Self {
            id: Uuid::new_v4(),
            kind: crate::entities::subagent::RunKind::Subagent,
            title: title.into(),
            parent: Uuid::new_v4(),
            parent_title: "Plans".into(),
            created_at: chrono::Utc::now() - chrono::Duration::minutes(10),
            finished_at: None,
            outcome: None,
            running: true,
            background: true,
            tokens: 0,
            position: None,
        }
    }
}

/// One of the app's own silent tasks on the tasks screen: running or idle,
/// which is all the slot registry can honestly say
/// (docs/research/tasks-screen.md §2.4, fork F2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppTask {
    pub kind: BackgroundKind,
    pub running: bool,
    /// Running, but not streaming: waiting for the silent lane's permit
    /// behind another task, or for room in the pool beside an interactive
    /// stream (docs/research/silent-tasks-budget.md §4.6). Read off the
    /// budget, never stored.
    pub waiting: bool,
}

/// The tasks screen's snapshot ([`AppEvent::TaskList`]): the runs — running
/// first, then landed, each half newest first, the landed half capped — and
/// the app's own silent tasks.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TaskList {
    pub runs: Vec<TaskRun>,
    /// How many landed runs fell past the cap (`TASK_LANDED_CAP`); the
    /// screen says "…and n more" rather than truncating silently.
    pub more_landed: usize,
    /// Every [`BackgroundKind`], in one fixed order.
    pub app: Vec<AppTask>,
}

/// How many landed runs the tasks screen lists (docs/research/tasks-screen.md
/// fork F1): the most recent ones, with a counted remainder line.
pub const TASK_LANDED_CAP: usize = 50;

/// projection.
#[derive(Debug, Clone)]
pub enum AppEvent {
    /// The status of one of the servers changed — a snapshot of all of them (chat/
    /// embeddings/impersonation).
    ServerStatus(ServerStatuses),
    /// The full list of visible chats (for the overlay). Sent whenever the set changes.
    ChatList(Vec<ChatSummary>),
    /// A chat's title changed (manual/auto rename). UI updates the active chat's
    /// feed header without a rebuild (`ChatList` updates the list).
    ChatRenamed { id: Uuid, title: String },
    /// The result of a content search (a reply to [`AppCommand::SearchChats`]):
    /// the chats having at least one matching message. `query` is echoed back so
    /// a late reply can be told from the current one.
    ///
    /// `chat_ids: None` means **"not a searchable query — do not filter"**
    /// (nothing survived trigram's 3-character floor, or the query failed).
    /// Deliberately an `Option` rather than "all the ids": the event must never
    /// claim that every chat matched.
    ChatSearchResults {
        query: String,
        chat_ids: Option<Vec<Uuid>>,
    },
    /// The result of a message-level content search (a reply to
    /// [`AppCommand::SearchMessages`]): matching messages **grouped by chat**
    /// (fork S2), chats in the chat list's order, messages in chat order.
    /// `query` is echoed back so a late reply can be told from the current one.
    ///
    /// `total` is the true number of matching messages, which may exceed the
    /// hits carried here ([`crate::features::chat_search::HIT_CAP`]) — the screen shows
    /// "showing N of M" rather than truncating silently.
    MessageSearchResults {
        query: String,
        groups: Vec<crate::features::chat_search::SearchGroup>,
        total: usize,
    },
    /// An error from a chat-list operation (auto-title/delete/clone). Shown in
    /// the list overlay's dedicated status area (not the chat feed), if it's open.
    ChatListError(String),
    /// Write text to the clipboard (a UI-layer side effect). The orchestrator built
    /// the chat conversation text; runtime writes it to the clipboard and sends a
    /// confirmation/error to the overlay.
    CopyToClipboard(String),
    /// The full list of visible profiles (for the selection overlay when creating a
    /// chat).
    ProfileList(Vec<ProfileSummary>),
    /// A full settings snapshot for the settings screen (config + full profiles).
    /// Sent on startup and after any config/profile edit. See spec §11.6.
    /// `language_locked` — ids of profiles whose scaffold language (axis A,
    /// docs/history/i18n.md) can no longer be changed: data has appeared (chats /
    /// "self-model" / notes). Computed by the orchestrator (the screen has no access to
    /// chats/the DB).
    /// `mcp` — a snapshot of the MCP host (a dynamic tool catalog for profile
    /// toggles + server statuses for rows in the "Tools" section); MCP tools are not
    /// part of the static `tool_catalog()`. Empty while the servers haven't
    /// come up / are disabled.
    /// `secrets_present` — which secrets are stored **on this machine** (the
    /// "configured" status shown by every secret field: provider keys, the backup
    /// password, MCP environment values). The secrets themselves aren't included
    /// in the snapshot: `config.api_keys` is cleared on emit — UI doesn't carry
    /// them even as ciphertext, and the orchestrator restores them on the way back
    /// (`handle_update_config`). See `shared::secrets`,
    /// docs/research/api-key-storage.md.
    Settings {
        config: Box<AppConfig>,
        profiles: Vec<Profile>,
        language_locked: Vec<uuid::Uuid>,
        mcp: crate::features::tools::mcp::McpSnapshot,
        secrets_present: Vec<crate::shared::secrets::SecretKey>,
    },
    /// The outcome of an MCP import (`AppCommand::ImportMcpServers`) — a
    /// localized one-line summary, or the reason it failed. Shown as the import
    /// row's value in settings, where the user is standing.
    McpImportResult(String),
    /// The role names to show in the feed — the active chat's profile
    /// `character_names` (spec §5.1). Sent on chat activation and whenever the
    /// profile is edited, so a name change applies to the open chat immediately.
    /// An empty field means "not set": the feed keeps its localized header.
    CharacterNames(CharacterNames),
    /// The active chat changed — UI rebuilds the feed from its messages and loads
    /// the saved draft into the input box (`draft`; empty for a new chat).
    ChatActivated {
        id: Uuid,
        title: String,
        messages: Vec<Message>,
        draft: String,
        /// The chat's stored collapse state for the feed's foldable blocks
        /// ("thoughts"/tool calls) — the counterpart of `draft` for the view
        /// (spec §11.3).
        feed_view: FeedView,
        /// Put the feed on this message instead of the tail, and highlight the
        /// query inside it (a jump from a search hit, `AppCommand::OpenChatAt`).
        /// `None` — every other activation, which must not highlight anything.
        /// See docs/history/chat-search-stage2.md §3 and §4a S3(b).
        focus: Option<FeedFocus>,
        /// The rolling summary and the id of the first message still sent
        /// verbatim, when this chat has been compacted **and** the feature is on
        /// — what the feed needs to draw the boundary (spec §6.7). `None` when
        /// there is no summary or the master switch is off, so turning the
        /// switch off also removes the divider.
        compaction: Option<(Uuid, String)>,
        /// `Some` when the activated "chat" is a **sub-agent transcript** inside
        /// another chat (spec §9.3.2, §11.2): the screen opens it read-only,
        /// with the persona as a system bubble at the top. `None` for a chat.
        child: Option<ChildView>,
        /// The generation in flight **on this conversation** — the running
        /// turn's chat, or the sub-agent transcript that turn is inside
        /// (docs/history/subagent-live.md §3.4–§3.5, §8): which turn (the
        /// chip's guard), which stream this feed accepts, and the text of the
        /// round in progress. `None` otherwise.
        live_turn: Option<Box<LiveTurn>>,
    },
    /// A running sub-agent transcript filed a round while it is the open
    /// conversation (docs/subagent-live.md §3.4): the messages to append to
    /// the feed. Guarded by `id` — a round for another conversation is dropped.
    TranscriptGrew { id: Uuid, messages: Vec<Message> },
    /// A running dialogue starts its next line while its transcript is the
    /// open conversation (spec §9.13): the side the coming stream belongs to,
    /// under the transcript's stream id — so participant `b`'s tokens draw in
    /// a `User` bubble, not the assistant's.
    TranscriptLine {
        generation_id: Uuid,
        role: MessageRole,
    },
    /// A running dialogue **edited** its open transcript — the director
    /// discarded or rewrote a line (spec §9.13) — so appending cannot express
    /// it: the feed is rebuilt from this full replacement. Guarded by `id`.
    TranscriptReset { id: Uuid, messages: Vec<Message> },
    /// The user's message was accepted (an echo for the feed).
    UserMessage(String),
    /// Restore text into the input box (after deleting the last exchange). A non-empty
    /// existing input isn't overwritten — the text is prepended to it (UI), with a
    /// separating space when neither boundary has its own.
    RestoreInput(String),
    /// The engine said what model it is running, or stopped being able to say.
    ///
    /// Only the **discovered** name travels: the screen already holds the
    /// configuration and prefers it, so this value never has two meanings.
    /// `None` — the engine cannot say, or was just replaced and has not been
    /// asked yet; the caption then falls back to showing nothing, exactly as it
    /// did before the engine was ever asked. See
    /// docs/research/external-model-name.md §4.
    EngineModel(Option<String>),
    /// How many requests the engine serves at once, when it said (`total_slots`
    /// of a `llama-server`'s `/props`): a hint next to the `sessions` field of
    /// the settings screen, never a value written into it (spec §11.6). `None`
    /// — it cannot say, or the engine was just replaced.
    EngineSlots(Option<u32>),
    /// The provider's model catalogue, for the row that asked
    /// ([`AppCommand::ListModels`]), already narrowed to what that slot can use
    /// ([`for_slot`](crate::shared::api::catalogue::for_slot)).
    ///
    /// `Err` is never a reason to change the row: it stays the text field it has
    /// always been and says this much (N5 of the research). An `Ok` that is
    /// **empty** is an answer too — Anthropic publishes no embedding model, and
    /// a router with nothing loaded lists nothing.
    ModelCatalogue {
        slot: crate::shared::api::catalogue::ModelSlot,
        models: crate::shared::api::catalogue::CatalogueAnswer,
    },
    /// The sampling fields the **endpoint's catalogue** publishes for the
    /// configured model, when it publishes any (spec §8,
    /// [docs/history/gateway-capabilities.md](../../docs/history/gateway-capabilities.md)).
    /// `None` — it said nothing, which is every local server and every cloud, and
    /// nothing narrows. Discovered once per applied engine and pushed like
    /// [`AppEvent::EngineSlots`]: the UI is told, it never asks.
    EngineSamplingFields(Option<std::sync::Arc<[String]>>),
    /// The assistant's reply generation has started. `model` — the model the
    /// turn is going to, so the live bubble's header can name it right away
    /// (`interface.show_model_name`, spec §11.3); the very same value the
    /// finished message records in its metadata snapshot, so the header does
    /// not change when the turn ends. `None` — the mode names no model
    /// (a managed server with no GGUF path yet).
    GenerationStarted {
        generation_id: Uuid,
        model: Option<String>,
        /// The turn **continues** the trailing assistant message in place
        /// (`/continue`, spec §6.4): the feed re-opens the last assistant
        /// bubble for streaming instead of pushing a fresh one, and appends
        /// with no separator — the seam is the model's own.
        continuation: bool,
    },
    /// A delta of the reply's main text.
    Chunk { generation_id: Uuid, text: String },
    /// A delta of "thoughts" (CoT).
    Thoughts { generation_id: Uuid, text: String },
    /// The current generation's live token counter. `completion` — reply tokens
    /// generated so far (accumulated across agentic-loop rounds); `context` — tokens
    /// in the prompt (the whole conversation); `None` leaves the previous value
    /// untouched. `context_exact` — whether this number is exact, from the server's
    /// `usage` (otherwise a client-side estimate, UI marks it with `~`). `reasoning` —
    /// "thoughts" tokens (included in `completion`); `None` leaves the previous value
    /// untouched (known only from `usage`, not from the stream).
    /// UI shows "conversation + reply" in the status bar, see spec §11.1.
    TokenUsage {
        generation_id: Uuid,
        completion: u64,
        context: Option<u64>,
        context_exact: bool,
        reasoning: Option<u32>,
    },
    /// The agentic loop is holding a **dangerous** tool call and asking the user
    /// whether to run it (`tools.confirm_dangerous`, spec §9.8). The turn is
    /// parked until an [`AppCommand::ConfirmTool`] carrying the same
    /// `generation_id` comes back, or until the turn is cancelled.
    ToolConfirmRequest {
        generation_id: Uuid,
        /// The call's id — echoed back so a reply cannot answer the wrong call
        /// when the model made several in one round.
        call_id: String,
        name: String,
        arguments: String,
        /// For a `python_exec` call: the chat's files it would copy into the sandbox,
        /// already resolved, and whether the sandbox has the network
        /// (docs/history/sandbox-file-exchange.md §12 T6). The popup's compact view of the
        /// arguments drops arrays, so without this the argument that decides what leaves
        /// the chat would not be shown at all. `None` for every other tool.
        inputs: Option<crate::features::chat_inputs::ConfirmInputs>,
    },
    /// A tool call is about to execute (spec §11.3): the feed draws its card
    /// at once, marked *running*, so a long call — a sub-agent run, a build, a
    /// `python_exec` — is visible where it happens rather than only as a
    /// status-bar chip, and the turn never looks idle (docs/lessons.md §4).
    /// [`AppEvent::ToolCall`] with the same `call_id` completes the card.
    ToolCallStarted {
        generation_id: Uuid,
        call_id: String,
        name: String,
        arguments: String,
    },
    /// A tool was called and executed (for the tool block in the feed). See spec §6.3,
    /// §11.3. Completes the card [`AppEvent::ToolCallStarted`] opened for the
    /// same `call_id`, or adds one when none was opened.
    ToolCall {
        generation_id: Uuid,
        call_id: String,
        name: String,
        arguments: String,
        result: String,
        /// How many images the call returned (spec §9.10). A count, not the pixels: the
        /// feed shows a chip, and megabytes of base64 have no business travelling to a
        /// widget that renders one line.
        images: usize,
    },
    /// The assistant decided to write **another** message (the tool
    /// `send_followup_message`): UI finishes the current bubble and starts a new one,
    /// which will receive the next round's text. See spec §9.3.
    AssistantContinue { generation_id: Uuid },
    /// The assistant decided to **rewrite** the current message (the tool
    /// `rewrite_current_message`): UI discards the already-accumulated text of the
    /// current bubble; the rewritten reply will go into it. See spec §9.3.
    AssistantRewrite { generation_id: Uuid },
    /// Generation finished.
    Finished {
        generation_id: Uuid,
        reason: FinishReason,
        /// `/continue` would resume what this turn left behind — the mode
        /// supports continuation and an interrupted tail exists — so the
        /// feed's interruption notes may name the command (fork F9; a note
        /// naming a command that would refuse is the project's oldest defect,
        /// docs/lessons.md §4).
        continuable: bool,
    },
    /// Impersonation started: UI hides the input box and shows a streaming preview
    /// of the reply (pre-filled with the already-typed text). See spec §11.8.
    ImpersonationStarted { generation_id: Uuid },
    /// A text delta of the impersonated reply (into the preview).
    ImpersonationChunk { generation_id: Uuid, text: String },
    /// Impersonation finished. On `Stop`/`Length` UI inserts the accumulated text into
    /// the input box; on `Cancelled`/`Error` — discards it (the input box is unchanged).
    ImpersonationFinished {
        generation_id: Uuid,
        reason: FinishReason,
    },
    /// Progress of background file indexing into RAG (the `/rag add` command). See
    /// spec §9.3.
    RagProgress(RagProgress),
    /// Outcome of a `/file` command (attached/removed/list/error) — a note in the
    /// feed. See docs/file-attachments.md.
    FileProgress(FileProgress),
    /// What the assistant changed in the active chat's project, for the changes
    /// screen (spec §9.12). Built off the runtime by the orchestrator; the
    /// screen is a pure projection of it.
    WorkspaceChanges(Box<crate::features::workspace_diff::ChangeSet>),
    /// Outcome of a `/project` command (attached/detached/status/error) — a note
    /// in the feed. See docs/history/code-workspace.md, spec §9.12.
    ProjectProgress(crate::features::project_command::ProjectProgress),
    /// The active chat's attachment cards — for the status-bar chip (attachments
    /// cost tokens on every turn, so their presence has to be visible). Sent on
    /// chat activation and after every attach/remove, like `CharacterNames`.
    /// Cards only: the text itself never travels through the event channel.
    Attachments(Vec<AttachmentInfo>),
    /// Outcome of an `/image` command (staged/unstaged/list/error) — a note in the feed.
    /// See spec §9.10.
    ImageProgress(ImageProgress),
    /// The images staged for the next message — for the status-bar chip. Sent on chat
    /// activation, after every attach/remove, and when a turn consumes the staged set
    /// (then empty). Cards only: the payload never travels through the event channel.
    StagedImages(Vec<ImageInfo>),
    /// A snapshot of the active profile's "self-model" (a reply to `RequestSelfModel`)
    /// for the viewer screen (`F3`). `model: None` — the model hasn't been created
    /// yet. `Box` — a large type, don't bloat the enum. See
    /// docs/history/self-model-mvp.md.
    ///
    /// `names` are that **profile's** role names: the screen heads its two halves
    /// with the assistant's and the user's name (spec §17.7). They come from the
    /// profile rather than from the active chat, whose names a subagent
    /// transcript re-labels for the run (`Orchestrator::names_of`) — the
    /// self-model belongs to the profile, not to a run.
    SelfModelView {
        model: Box<Option<crate::entities::self_model::SelfModel>>,
        names: CharacterNames,
    },
    /// The "self-model" changed (via background reflection or turn tools) — a signal
    /// **with no snapshot**. If the `F3` screen is open, UI re-requests a fresh snapshot
    /// (`RequestSelfModel`); ignored when the screen is closed. See stage 5 of the
    /// refinements.
    SelfModelChanged,
    /// Whether speech (synthesis/playback) is active — for the quiet "♪ speaking" chip
    /// in the status bar. `true` when the task starts, `false` on its completion/
    /// cancellation. See spec §11.9.
    TtsActive(bool),
    /// Background task activity (auto-reflection/consolidation) for the quiet indicator
    /// in the status bar: `active=true` at the start, `false` on completion. See stage 5.
    BackgroundTask { kind: BackgroundKind, active: bool },
    /// How many sub-agent runs are out in the **background** right now
    /// (spec §9.3.2) — a quiet status-bar indicator with the count; `0`
    /// clears it.
    BackgroundRuns { out: u32 },
    /// The tasks screen's rows (spec §11.10): every run in flight or landed,
    /// and the app's own silent tasks. Sent in reply to
    /// [`AppCommand::RequestTasks`] and, unasked, whenever the rows change —
    /// a run starts, files a round, moves to a tool, ends or lands, a silent
    /// task begins or finishes, the chat list changes. The runtime refreshes
    /// an **open** tasks screen from it and never opens one on it: the
    /// screen opens on the key, and an unasked snapshot must not steal the
    /// screen the user is reading. `Box` — a large type, don't bloat the enum.
    TaskList(Box<TaskList>),
    /// Where a sub-agent run stands (spec §9.3.2): a quiet status-bar chip
    /// while the parent's turn is inside `call_subagent`, whose own stream is
    /// muted — without it the bar would read "generating" for minutes with
    /// nothing moving (docs/lessons.md §4). `None` — the run ended (the
    /// parent's turn goes on). Carries `generation_id` like every streaming
    /// event, so a cancelled turn's chip cannot linger.
    SubagentProgress {
        generation_id: Uuid,
        /// Which run the report is about: several can run at once (spec
        /// §9.3.2), and the screen keeps one line per running run, clearing
        /// the one whose `None` arrives.
        run: Uuid,
        progress: Option<SubagentProgress>,
    },
    /// A transient provider failure is being retried; the next attempt starts in
    /// `delay_secs`.
    ///
    /// Shown as a quiet, transient status-bar chip rather than a feed note: a note
    /// per attempt would be noise, while saying nothing for up to half a minute is
    /// the door left open (docs/lessons.md §4). Cleared by the next chunk or by
    /// `Finished`. Carries `generation_id` so a chip from a turn the user has since
    /// cancelled cannot linger (spec §4.4).
    Retrying {
        generation_id: Uuid,
        attempt: u32,
        max: u32,
        delay_secs: u64,
    },
    /// An error (for showing in UI).
    Error(String),
    /// A plain informational note in the feed — the counterpart of [`AppEvent::Error`]
    /// for an outcome that is not a failure ("nothing to compress yet"). Rendered
    /// with `push_note`, not `push_error`.
    Notice(String),
    /// A compaction finished: the chat's older messages are now represented in the
    /// request by a rolling summary (spec §6.7). Carries what the feed needs to
    /// draw the boundary — `boundary` is the id of the first message still sent
    /// verbatim — plus `folded`, how many messages the summary now covers, for the
    /// confirmation note. `chat.messages` is unchanged, so the feed keeps showing
    /// everything; only a divider appears.
    Compacted {
        chat_id: Uuid,
        boundary: Uuid,
        summary: String,
        folded: usize,
    },
}

/// The kind of background task for the status-bar indicator
/// (`AppEvent::BackgroundTask`). `Hash` — used as the key of the background-task slot
/// registry (`orchestrator::background`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BackgroundKind {
    /// Auto-reflection of the "self-model".
    Reflection,
    /// Auto-consolidation of notes ("sleep").
    Consolidation,
    /// Auto-consolidation of the "self-model" (self-model "sleep"): merge duplicate
    /// observations, compress a bloated description, link contradictions. See
    /// docs/history/self-model-consolidation.md.
    SelfConsolidation,
    /// Compressing the older part of a conversation into a rolling summary
    /// (`/compact`, spec §6.7).
    Compaction,
}

impl BackgroundKind {
    /// The bundle key of the task's name in the interface language — the
    /// tasks screen's row label, and the name a `/tasks stop` note quotes
    /// (spec §11.10, §11.7). One function for both surfaces, so the two
    /// cannot call one task two things
    /// (docs/research/tasks-stop-command.md §3.2).
    pub fn label_key(self) -> &'static str {
        match self {
            BackgroundKind::Reflection => "ui.tasks.app.reflection",
            BackgroundKind::Consolidation => "ui.tasks.app.consolidation",
            BackgroundKind::SelfConsolidation => "ui.tasks.app.self_consolidation",
            BackgroundKind::Compaction => "ui.tasks.app.compaction",
        }
    }
}