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
//! Managing the chat list: creation, switching, renaming, cloning,
//! copying the conversation, exporting it to a file, deletion, and saving the
//! draft.
use std::path::{Path, PathBuf};
use chrono::Utc;
use uuid::Uuid;
use crate::app::events::{AppEvent, FeedFocus};
use crate::entities::chat::FeedView;
use crate::features::export_command::ExportFormat;
use super::Orchestrator;
/// A path as the user should see it: absolute where that can be worked out, and
/// the path as given when it cannot (`canonicalize` needs the file to exist, so
/// this is called *after* the write — and on the failure paths it falls back
/// rather than hiding the name).
fn display_path(path: &Path) -> String {
std::fs::canonicalize(path)
.map(|p| {
// Windows' canonical form carries the `\\?\` verbatim prefix, which
// is correct and unreadable; the user is going to paste this
// somewhere.
let s = p.display().to_string();
s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()
})
.unwrap_or_else(|_| {
std::env::current_dir()
.map(|cwd| cwd.join(path).display().to_string())
.unwrap_or_else(|_| path.display().to_string())
})
}
impl Orchestrator {
/// Saves the input-box draft on the active chat (unsaved text). The write to
/// disk is debounced (`mark_dirty`); `modified_at` is NOT touched — editing a
/// draft shouldn't bump the chat up the list. See spec §11.7.
pub(super) fn handle_set_draft(&mut self, text: String) {
let Some(active_id) = self.active_id else {
return;
};
// A transcript has no draft: its input box is not for typing.
if let Some(chat) = self.chat_mut(active_id) {
if chat.draft == text {
return;
}
chat.draft = text;
self.mark_dirty(active_id);
}
}
/// Saves the feed's collapse state on the active chat (`Ctrl+T`/`Ctrl+O`).
/// Same rules as the draft above: debounced write, `modified_at` untouched —
/// folding a block away isn't a change to the conversation. See spec §11.3.
pub(super) fn handle_set_feed_view(&mut self, view: FeedView) {
let Some(active_id) = self.active_id else {
return;
};
// A transcript shares its parent's collapse state — it is part of the
// parent (spec §11.2), so `Ctrl+T`/`Ctrl+O` there fold the parent's blocks too.
let active_id = self.parent_of(active_id).unwrap_or(active_id);
if let Some(chat) = self.chat_mut(active_id) {
if chat.feed_view == view {
return;
}
chat.feed_view = view;
self.mark_dirty(active_id);
}
}
/// Stores whether the list shows a chat's sub-agent transcripts
/// (`Ctrl+O` in the list, `/subagents` in the chat; spec §11.2).
/// Like [`Self::handle_set_feed_view`]: the save is debounced and
/// `modified_at` is left alone — folding rows away is not a change to the
/// conversation. Unlike it, the target comes by id (the list toggles any
/// row's chat); a transcript's id folds its parent's list, and the updated
/// summaries go straight back out so an open list redraws.
pub(super) fn handle_set_children_expanded(&mut self, id: Uuid, expanded: bool) {
let id = self.parent_of(id).unwrap_or(id);
if let Some(chat) = self.chat_mut(id) {
if chat.children_expanded == expanded {
return;
}
chat.children_expanded = expanded;
self.mark_dirty(id);
self.emit_chat_list();
}
}
pub(super) fn handle_new_chat(&mut self, profile_id: Option<Uuid>) {
let chat = self.new_chat_value(profile_id);
let id = chat.id;
if let Err(err) = self.storage.json().save_chat(&chat) {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale()
.tf("ui.err.chat_create_failed", &[("err", &err.to_string())]),
));
return;
}
self.chats.insert(0, chat);
self.emit_chat_list();
self.activate(id);
}
pub(super) fn handle_switch(&mut self, id: Uuid) {
self.switch_to(id, None);
}
/// Opens a chat **on a specific message** (a jump from a search hit). Same
/// path as a plain switch — the focus rides along to the feed through
/// `ChatActivated`, carrying the query so the feed can highlight it inside
/// that message. See docs/history/chat-search-stage2.md §3 and §4a S3(b).
pub(super) fn handle_open_chat_at(&mut self, chat: Uuid, message: Uuid, query: String) {
self.switch_to(chat, Some(FeedFocus { message, query }));
}
/// Opens a chat on its **first message matching `query`** (`Enter` in the
/// chat list's content mode). Resolving the match needs both the index and
/// the chat, so it happens here rather than in the widget; when nothing
/// resolves the chat opens at its tail — a plain switch, not an error.
pub(super) fn handle_open_chat_at_first_match(&mut self, chat: Uuid, query: &str) {
let focus = self
.first_match_in_chat(chat, query)
.map(|message| FeedFocus {
message,
query: query.to_string(),
});
self.switch_to(chat, focus);
}
/// The shared switch path. `focus` — a message to put the feed on, plus the
/// query to highlight inside it. `id` may name a sub-agent transcript, which
/// opens read-only (spec §11.2).
fn switch_to(&mut self, id: Uuid, focus: Option<FeedFocus>) {
if self.active_id == Some(id) {
// A plain switch to the open chat is a no-op, but a jump still has
// to move the feed — re-emit so the focus reaches it.
if focus.is_some() {
self.activate_focused(id, focus);
}
return;
}
// Speech is stopped per the setting (on by default — otherwise you'd
// suddenly be listening to a different chat). See spec §11.9.
if self.config.tts.stop_on_chat_switch {
self.stop_tts();
}
// If generation is running — cancel it (the partial reply is saved for
// the original chat when the GenResult arrives). The one exception:
// moving between the running turn's chat and its sub-agent transcript
// (docs/subagent-live.md §3.5) — looking at the run is not leaving it.
if !self.switch_within_turn(id)
&& let Some(token) = self.gen_state.request_cancel()
{
token.cancel();
}
if self.view(id).is_some() {
self.activate_focused(id, focus);
}
}
pub(super) fn handle_rename(&mut self, id: Uuid, title: String) {
let title = title.trim().to_string();
if title.is_empty() {
return;
}
if let Some(chat) = self.chat_mut(id) {
chat.title = title.clone();
// Both manual routes (the list's `F2` editor, `/rename <title>`)
// funnel here: from now on the automatic titling leaves this chat
// alone (spec §11.2). Model-written titles never set this.
chat.renamed_manually = true;
self.mark_dirty(id);
} else if !self.with_child_mut(id, |run| {
// A transcript is renamed by the same two routes, with the same
// consequence; the parent's `modified_at` is left alone — a rename
// is not a conversation change (the draft's rule).
run.title = title.clone();
run.renamed_manually = true;
}) {
return;
}
self.emit_chat_list();
let _ = self.evt_tx.send(AppEvent::ChatRenamed { id, title });
}
/// A list operation a sub-agent transcript cannot take (delete, clone):
/// says so in the list's status area and names the routes that do
/// remove one (spec §11.2, docs/lessons.md §4).
fn refuse_on_child(&self, id: Uuid) -> bool {
if self.parent_of(id).is_none() {
return false;
}
let _ = self.evt_tx.send(AppEvent::ChatListError(
self.ui_locale().t("ui.chatlist.err.child_locked").into(),
));
true
}
pub(super) fn handle_clone(&mut self, id: Uuid) {
if self.refuse_on_child(id) {
return;
}
let Some(src) = self.chats.iter().find(|c| c.id == id) else {
return;
};
let now = chrono::Utc::now();
let mut clone = src.clone();
clone.id = Uuid::new_v4();
// The copied messages carry the transcripts along; each needs an id of
// its own or two chats answer to one `chat://` prefix.
clone.reid_children();
clone.title = self
.ui_locale()
.tf("ui.chat.clone_suffix", &[("orig", &src.title)]);
clone.created_at = now;
clone.modified_at = now;
let new_id = clone.id;
if let Err(err) = self.storage.json().save_chat(&clone) {
let _ = self.evt_tx.send(AppEvent::ChatListError(
self.ui_locale()
.tf("ui.err.chat_clone_failed", &[("err", &err.to_string())]),
));
return;
}
self.chats.insert(0, clone);
self.emit_chat_list();
self.activate(new_id);
}
/// Copies the whole chat conversation to the clipboard (spec §11.2): the orchestrator
/// (the owner of `Chat`) builds the text and emits `CopyToClipboard` — writing to the
/// clipboard and the confirmation are done by the UI layer (`runtime`). An empty chat → a clear error.
pub(super) fn handle_copy_chat(&mut self, id: Uuid) {
let Some(view) = self.view(id) else {
return;
};
let (title, messages) = match view {
super::ChatView::Top(chat) => (chat.title.clone(), chat.messages.clone()),
super::ChatView::Child { run, .. } => (run.title.clone(), run.messages.clone()),
};
// Role labels come from the chat's profile (spec §5.1): a custom name replaces
// the localized "User:"/"Assistant:"; a transcript's are the parent persona
// and the sub-agent (see `names_of`).
let names = self.names_of(id);
match crate::features::chat_export::format_conversation(
&title,
&messages,
&self.config.copy,
&names,
self.ui_locale(),
) {
Some(text) => {
let _ = self.evt_tx.send(AppEvent::CopyToClipboard(text));
}
None => {
let _ = self.evt_tx.send(AppEvent::ChatListError(
self.ui_locale().t("ui.err.nothing_to_copy").into(),
));
}
}
}
/// Writes a chat to a file (`/export`, docs/history/chat-export-file.md).
///
/// The orchestrator finishes this one itself rather than handing content
/// back to the UI: it owns `Chat`, it already does disk I/O, and the answer
/// the user needs is a **path**. A relative path — or the generated name a
/// bare `/export` gets — resolves against the process's current working
/// directory (fork F3), which is where the user launched the app.
pub(super) fn handle_export_chat(
&mut self,
id: Uuid,
format: ExportFormat,
path: Option<&str>,
) {
let ui = self.ui_locale();
// A transcript exports as the chat it looks like: a copy of its
// messages under its own title, in its parent's profile — the point of
// an export is a copy (docs/research/subagent-chats.md §3.6).
let transcript: Option<crate::entities::chat::Chat> = match self.view(id) {
Some(super::ChatView::Child { parent, run }) => {
let mut chat = crate::entities::chat::Chat::from_profile(
&crate::entities::profile::Profile::new("", &run.system_message),
run.title.clone(),
);
chat.id = run.id;
chat.profile_id = parent.profile_id;
chat.created_at = run.created_at;
chat.modified_at = run.finished_at.unwrap_or(run.created_at);
chat.messages = run.messages.clone();
Some(chat)
}
_ => None,
};
let Some(chat) = transcript
.as_ref()
.or_else(|| self.chats.iter().find(|c| c.id == id))
else {
return;
};
let content = match format {
ExportFormat::Markdown => {
let names = self.names_of(id);
match crate::features::chat_export::format_conversation(
&chat.title,
&chat.messages,
&self.config.copy,
&names,
ui,
) {
Some(text) => text,
// The same refusal the clipboard gives for an empty chat:
// writing a file with nothing in it would be worse.
None => {
let _ = self
.evt_tx
.send(AppEvent::Error(ui.t("ui.err.nothing_to_copy").into()));
return;
}
}
}
ExportFormat::Json => {
// The format requires the chat's profile in the same file.
let Some(profile) = self.profiles.iter().find(|p| p.id == chat.profile_id) else {
let _ = self
.evt_tx
.send(AppEvent::Error(ui.t("ui.export.err.no_profile").into()));
return;
};
let doc = crate::features::chat_export::to_import_json(chat, profile);
match serde_json::to_string_pretty(&doc) {
Ok(text) => text,
Err(err) => {
let _ = self.evt_tx.send(AppEvent::Error(
ui.tf("ui.export.err.failed", &[("err", &err.to_string())]),
));
return;
}
}
}
};
let target = match path {
Some(p) => PathBuf::from(p),
None => PathBuf::from(crate::features::chat_export::export_filename(
&chat.title,
format.extension(),
Utc::now(),
)),
};
// Refuse an existing file (fork F5): overwriting somebody's export
// silently is the kind of loss this project avoids everywhere else.
if target.exists() {
let _ = self.evt_tx.send(AppEvent::Error(
ui.tf("ui.export.err.exists", &[("path", &display_path(&target))]),
));
return;
}
if let Err(err) = std::fs::write(&target, content) {
let _ = self.evt_tx.send(AppEvent::Error(ui.tf(
"ui.export.err.write",
&[("path", &display_path(&target)), ("err", &err.to_string())],
)));
return;
}
// The absolute path, because a relative one answers "where?" with the
// question again — and finding the file is the whole point when the
// terminal is on another machine.
let note = match format {
ExportFormat::Markdown => ui.tf("ui.export.done", &[("path", &display_path(&target))]),
// Said on every JSON export, not buried in the docs: the format has
// nowhere to put tool calls, and noticing that later is worse.
ExportFormat::Json => ui.tf("ui.export.done_json", &[("path", &display_path(&target))]),
};
let _ = self.evt_tx.send(AppEvent::Notice(note));
}
pub(super) fn handle_delete(&mut self, id: Uuid) {
if self.refuse_on_child(id) {
return;
}
// Unconditional (not a setting): the chat being spoken is about to disappear.
if self.active_id == Some(id) {
self.stop_tts();
}
match self.storage.json().hide_chat(id) {
Ok(false) => return,
Err(err) => {
let _ = self.evt_tx.send(AppEvent::ChatListError(
self.ui_locale()
.tf("ui.err.chat_delete_failed", &[("err", &err.to_string())]),
));
return;
}
Ok(true) => {}
}
// A run out in the background for this chat has nowhere to land.
self.cancel_background_runs_of(id);
self.chats.retain(|c| c.id != id);
self.saves.forget(id);
// Staging is keyed by chat, so a deleted chat's slot has to go with it —
// otherwise its images would be carried into whatever the screen shows next.
self.forget_staged_images(id);
// A hidden chat never appears in the list, so it must not appear in
// content-search results either (see [`super::search`]).
self.forget_chat_index(id);
// If the active one was deleted — pick another (or create a new one).
if self.active_id == Some(id) {
self.active_id = None;
if let Some(next) = self.chats.first().map(|c| c.id) {
self.emit_chat_list();
self.activate(next);
} else {
self.handle_new_chat(None);
}
} else {
self.emit_chat_list();
}
}
}