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
//! The launcher's `in` field: typing a directory and being offered one.
//!
//! [`dirs`](super::dirs) computes the suggestions; this owns the field they are
//! offered into — the editing state, the cursor, and the check that a typed path
//! is a directory that exists before a launch is allowed to take it. The split
//! is so that the matching logic can be tested without an `App` at all.
use super::*;
/// Directories the launcher's `in` field will remember, at most.
///
/// The list is a way of not having to recall a path, so it is worth being
/// generous — but it is filtered by what is typed, and a machine with hundreds
/// of transcripts would otherwise stat every project on it on every keystroke.
const MAX_KNOWN_DIRS: usize = 40;
impl App {
/// Open the launcher's directory field, prefilled with where it would go.
///
/// Prefilled with `~` spelling rather than the absolute path: that is how
/// the line already reads, and a field that changed what it showed the
/// moment it became editable would look like it had lost the setting.
pub(super) fn edit_launch_cwd(&mut self) {
let prefill = self
.launch_cwd
.as_ref()
.map(|dir| crate::util::tildify(&dir.to_string_lossy()))
.unwrap_or_default();
self.launch_cwd_input.set(prefill);
self.launch_cwd_bad = false;
self.launch_cwd_known = self.known_dirs();
self.launch_cwd_suggest();
// Asked for as the field opens, and answered a moment later by a worker
// walking the home directory. Sent every time rather than once per
// session: a repository pulled while cctop is running is the case this
// exists for, and the walk is tens of milliseconds on a thread of its
// own. The list is not rebuilt when the answer lands — see
// [`App::got_repos`] — so the suggestions under the cursor stay valid.
let _ = self.tx.send(worker::Request::Repos);
self.mode = Mode::LaunchCwd;
self.needs_redraw = true;
}
/// Repositories the worker found, kept for the next time the field opens.
///
/// Deliberately not re-suggesting from here. The list is a snapshot so that
/// Enter takes the directory that is highlighted, and a walk landing a
/// moment after the field opened would move the highlight under the cursor
/// without the arrow keys having been pressed. The repositories are there
/// for the next opening, which is the one after the pull.
pub(super) fn got_repos(&mut self, repos: Vec<std::path::PathBuf>) {
self.launch_cwd_repos = repos;
}
/// Directories agents are known to have run in, last used first.
///
/// Drawn from the dashboard's own rows, which is the list of projects cctop
/// has any evidence of, plus where it was started and where this launch was
/// already headed — those two are what "in this directory" and the line the
/// field replaces already meant, and a suggestion list that omitted them
/// would look like it had forgotten them.
///
/// A session that ran inside a repository offers the repository, not the
/// directory it was sitting in. `label_source` is a working directory, and
/// an agent that started in `cctop/src/ui` records exactly that — which
/// answers a question nobody asked, and with the deeper of the two paths to
/// remember. The checkout is found through [`tree::locate_cached`], the same
/// resolution the tree view groups by, so a linked worktree is understood
/// here exactly as it is there. Two checkouts of one repository stay two
/// answers: they are two directories to start an agent in.
///
/// A directory that is in no repository is offered as itself. Work outside a
/// checkout is real work, and this list exists to avoid the refusal that a
/// directory cctop would itself accept would only invite.
///
/// Only directories that still exist: a session's recorded project can have
/// been moved or deleted since, and offering one leads to the refusal this
/// list exists to avoid.
pub(super) fn known_dirs(&self) -> Vec<std::path::PathBuf> {
let mut recent: Vec<&Session> = self.sessions.iter().collect();
// Last used first, so the projects worked on today are the ones on
// screen before anything is typed. Ties go to the session that began
// more recently, which is the only thing left that separates them: two
// sessions touched in the same refresh would otherwise come out in
// whichever order the rows arrived, and that order changes.
recent.sort_by(|a, b| {
b.last_active
.cmp(&a.last_active)
.then_with(|| b.started_at.cmp(&a.started_at))
});
let mut seen = HashSet::new();
self.launch_cwd
.clone()
.into_iter()
.chain(self.launch_root.clone())
.chain(
self.launch_offer
.iter()
.filter_map(|c| c.cwd().map(std::path::Path::to_path_buf)),
)
.chain(
recent
.iter()
.filter(|s| !s.label_source.is_empty())
// `locate` answers (common git dir, checkout root); the root
// is the directory, and `None` — a relative path, or a
// directory in no repository — leaves it as it was.
.map(|s| match tree::locate_cached(&s.label_source) {
Some((_, root)) => root,
None => std::path::PathBuf::from(&s.label_source),
}),
)
// Repositories found on disk, after the ones an agent has run in:
// a project you worked in today is the likelier answer, and a
// repository nobody has tried yet is a fallback rather than a
// headline. The `seen` set does the rest — one a session has already
// contributed is not offered twice.
.chain(self.launch_cwd_repos.iter().cloned())
.filter(|dir| seen.insert(dir.clone()) && dir.is_dir())
.take(MAX_KNOWN_DIRS)
.collect()
}
/// Recompute what the field is offering, after anything that changed what
/// is in it.
///
/// The pick goes with it: a suggestion highlighted for the old text would
/// otherwise still be what Enter took, which is a directory the field is no
/// longer showing.
pub(super) fn launch_cwd_suggest(&mut self) {
self.launch_cwd_hits = dirs::suggest(&self.launch_cwd_input, &self.launch_cwd_known);
self.launch_cwd_pick = None;
}
/// Move the cursor through the suggestions, or back into the text.
///
/// Leaving the list at the top rather than wrapping to the bottom is what
/// makes the text reachable again: the field is the thing being edited, and
/// a list that cycled would trap the cursor in it.
pub(super) fn step_launch_cwd(&mut self, down: bool) {
let last = self.launch_cwd_hits.len().saturating_sub(1);
self.launch_cwd_pick = match (self.launch_cwd_pick, down) {
(_, _) if self.launch_cwd_hits.is_empty() => None,
(None, true) => Some(0),
(None, false) => Some(last),
(Some(i), true) => Some((i + 1).min(last)),
(Some(0), false) => None,
(Some(i), false) => Some(i - 1),
};
self.needs_redraw = true;
}
/// Fill in as much of the path as the suggestions agree on.
///
/// Completing re-suggests: `~/c` completing to `~/cctop/` is only useful if
/// the list then shows what is inside it, which is how a deep path gets
/// walked to without being remembered.
pub(super) fn complete_launch_cwd(&mut self) {
// The highlighted one, if the cursor is in the list — there Tab means
// "that one", the same as Enter, minus the launching.
let filled = match self
.launch_cwd_pick
.and_then(|i| self.launch_cwd_hits.get(i))
{
Some(dir) => format!("{}/", crate::util::tildify(&dir.to_string_lossy())),
None => match dirs::complete(&self.launch_cwd_input, &self.launch_cwd_hits) {
Some(filled) => filled,
None => return,
},
};
if filled.chars().count() > input::MAX_PATH_INPUT {
return;
}
self.launch_cwd_input.set(filled);
self.launch_cwd_bad = false;
self.launch_cwd_suggest();
self.needs_redraw = true;
}
/// Take the highlighted suggestion, or what is typed if there is none.
///
/// A suggestion is a directory this code listed off the disk moments ago, so
/// it is taken without the check the typed path gets — and it is taken by
/// filling the field with it first, so that a suggestion which has since
/// been deleted is refused in the field like anything else.
pub(super) fn take_launch_cwd(&mut self) {
if let Some(dir) = self
.launch_cwd_pick
.and_then(|i| self.launch_cwd_hits.get(i))
{
self.launch_cwd_input = crate::util::tildify(&dir.to_string_lossy()).into();
}
self.accept_launch_cwd();
}
/// Take the typed directory, if it names one.
///
/// Checked here rather than at launch. A path that does not exist fails
/// somewhere inside the shim with a message about spawning, by which point
/// the launcher is gone and there is nothing left to correct.
pub(super) fn accept_launch_cwd(&mut self) {
let typed = self.launch_cwd_input.trim();
// Empty means "wherever cctop was started", which is what the launcher
// offers by default and what the footer calls "this directory".
let taken = match typed.is_empty() {
true => None,
false => {
let path = std::path::PathBuf::from(crate::util::untildify(typed));
if !path.is_dir() {
self.launch_cwd_bad = true;
return;
}
Some(path)
}
};
// Cleared on the way out, not only on the way in: a path corrected
// after a refusal would otherwise carry the mark back to a field that
// now holds something perfectly good.
self.launch_cwd_bad = false;
self.launch_cwd = taken;
self.mode = Mode::Launch;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::tests::{key, session, test_app};
use ratatui::crossterm::event::KeyCode;
use std::sync::mpsc::channel;
/// The suggestions are part of the footer, not part of the list: a short
/// terminal has to lose choices before it loses the paths being offered,
/// because they are what the field is being typed against.
#[test]
fn the_directory_suggestions_stay_on_screen_under_a_long_list() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let root = tempfile::tempdir().expect("tempdir");
let project = root.path().join("chosen-project");
std::fs::create_dir(&project).expect("mkdir");
let mut app = test_app();
app.launch_offer = (0..20)
.map(|i| tabs::Choice::Start(vec![format!("agent-{i}")]))
.collect();
app.launch_cwd_known = vec![project.clone()];
app.launch_cwd_input = Default::default();
app.launch_cwd_suggest();
app.launch_cwd_pick = Some(0);
app.mode = Mode::LaunchCwd;
let (cols, rows) = (80u16, 14u16);
let mut terminal = Terminal::new(TestBackend::new(cols, rows)).expect("backend");
let mut layout = render::Layout::default();
terminal
.draw(|frame| layout = render::draw(frame, &mut app))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
let text: String = (0..rows)
.map(|y| {
(0..cols)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect();
assert!(
text.contains("chosen-project"),
"the suggestion is not drawn"
);
assert!(text.contains("Tab fill in"), "the key that fills it in");
// And it is clickable where it was drawn, rather than on a row the
// choices above it also claim.
let (row, i) = *layout
.launch_cwd_rows
.first()
.expect("no clickable suggestion");
assert_eq!(i, 0);
assert!(row < rows, "the suggestion is drawn off screen");
assert!(
!layout.launch_rows.iter().any(|(y, _)| *y == row),
"a choice and a suggestion share row {row}"
);
}
/// The launcher's directory is a field, not a caption. A `claude` opened on
/// the wrong project reads its way into the wrong repository before anyone
/// notices, and until now the only way to change it was to restart cctop
/// somewhere else.
#[test]
fn the_launchers_directory_can_be_typed_and_is_checked_before_it_is_taken() {
let dir = tempfile::tempdir().expect("tempdir");
let mut app = App::new(Plan::Retail, channel().0);
app.launch_cwd = Some(dir.path().to_path_buf());
// Opens prefilled with what it would have used, so nothing looks lost.
app.edit_launch_cwd();
assert_eq!(app.mode, Mode::LaunchCwd);
assert_eq!(
app.launch_cwd_input,
crate::util::tildify(&dir.path().to_string_lossy())
);
// A directory that is not one is refused where it was typed, and the
// field stays open. Failing at launch instead would report it from
// inside the shim, after the launcher had gone.
app.launch_cwd_input = dir
.path()
.join("nope")
.to_string_lossy()
.into_owned()
.into();
app.accept_launch_cwd();
assert!(app.launch_cwd_bad);
assert_eq!(app.mode, Mode::LaunchCwd, "the field stays open");
assert_eq!(app.launch_cwd.as_deref(), Some(dir.path()), "unchanged");
// A real one is taken.
let sub = dir.path().join("work");
std::fs::create_dir(&sub).expect("mkdir");
app.launch_cwd_input = sub.to_string_lossy().into_owned().into();
app.accept_launch_cwd();
assert!(!app.launch_cwd_bad);
assert_eq!(app.mode, Mode::Launch);
assert_eq!(app.launch_cwd.as_deref(), Some(sub.as_path()));
// Empty means where cctop was started, which is what the footer calls
// "this directory" — not an error, and not the previous value.
app.edit_launch_cwd();
app.launch_cwd_input = " ".into();
app.accept_launch_cwd();
assert_eq!(app.launch_cwd, None);
assert_eq!(app.mode, Mode::Launch);
}
/// The field is the only place in cctop where a path has to be produced from
/// memory, so it offers what it can see: the projects agents have run in
/// before anything is typed, the directories under whatever is typed after.
#[test]
fn the_directory_field_offers_paths_instead_of_asking_you_to_recall_them() {
let root = tempfile::tempdir().expect("tempdir");
let project = root.path().join("api");
let deep = project.join("service");
std::fs::create_dir_all(&deep).expect("mkdir");
let gone = root.path().join("deleted");
let mut app = test_app();
// One project that still exists and one that does not: a recorded
// directory outlives the checkout it named.
app.sessions = vec![
session("a", true, &project.to_string_lossy()),
session("b", false, &gone.to_string_lossy()),
];
app.launch_root = None;
app.edit_launch_cwd();
// Nothing typed, and the project is already on screen. The one that has
// since been deleted is not, because taking it could only fail.
assert_eq!(app.launch_cwd_hits, vec![project.clone()]);
assert!(!app.launch_cwd_hits.contains(&gone));
// The arrows move into the list and back out of it. Out, because the
// field is still what is being typed in.
app.on_key(key(KeyCode::Down));
assert_eq!(app.launch_cwd_pick, Some(0));
app.on_key(key(KeyCode::Up));
assert_eq!(app.launch_cwd_pick, None);
// Tab on the highlighted project fills it in and then shows what is
// inside it, which is how a path nobody remembers gets walked to.
app.on_key(key(KeyCode::Down));
app.on_key(key(KeyCode::Tab));
assert_eq!(
app.launch_cwd_input,
format!("{}/", project.to_string_lossy())
);
assert_eq!(app.launch_cwd_hits, vec![deep.clone()]);
assert_eq!(app.launch_cwd_pick, None, "a fresh list picks nothing");
// Enter on a suggestion takes it without the refusal a mistyped path
// gets — it was listed off the disk moments ago.
app.on_key(key(KeyCode::Down));
app.on_key(key(KeyCode::Enter));
assert_eq!(app.mode, Mode::Launch);
assert!(!app.launch_cwd_bad);
assert_eq!(app.launch_cwd.as_deref(), Some(deep.as_path()));
// Typing still decides on its own: a path with no match offers nothing
// and is refused where it was typed, exactly as before.
app.edit_launch_cwd();
app.launch_cwd_input = root
.path()
.join("nowhere")
.to_string_lossy()
.into_owned()
.into();
app.launch_cwd_suggest();
assert!(app.launch_cwd_hits.is_empty());
app.on_key(key(KeyCode::Enter));
assert!(app.launch_cwd_bad);
assert_eq!(app.mode, Mode::LaunchCwd);
}
/// The field offers the repository, not the directory inside it that an
/// agent happened to be sitting in.
///
/// A session's `cwd` is whatever it was launched in, and an agent that
/// started in `cctop/src/ui` records exactly that. Offering it back answers
/// a question nobody asked: nobody means "the ui directory of cctop" when
/// they want to work on cctop, and it is the deeper of the two paths to
/// have to remember.
#[test]
fn the_directory_field_offers_the_repository_and_not_the_directory_inside_it() {
let root = tempfile::tempdir().expect("tempdir");
let repo = root.path().join("cctop");
let deep = repo.join("src/ui");
// The `.git` sits at the checkout root, and the agent ran two levels
// below it — which is the whole point.
std::fs::create_dir_all(&deep).expect("deep");
std::fs::create_dir_all(repo.join(".git")).expect("repo");
std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("HEAD");
// A second checkout of the same repository, so the two are told apart.
let other = root.path().join("cctop-worktree");
std::fs::create_dir_all(other.join(".git")).expect("worktree");
std::fs::write(other.join(".git/HEAD"), "ref: refs/heads/wip\n").expect("HEAD");
let plain = root.path().join("not-a-repo");
std::fs::create_dir_all(&plain).expect("plain");
let mut app = test_app();
app.sessions = vec![
session("a", true, &deep.to_string_lossy()),
session("b", false, &other.to_string_lossy()),
session("c", false, &plain.to_string_lossy()),
];
app.launch_root = None;
app.launch_cwd = None;
let known = app.known_dirs();
assert!(
known.contains(&repo),
"the repository is not offered: {known:?}"
);
assert!(
!known.contains(&deep),
"the subdirectory is offered instead of the repository: {known:?}"
);
// A second checkout is its own answer: it is a different directory to
// start an agent in, even though it is the same repository.
assert!(known.contains(&other), "{known:?}");
// And work outside any repository is still offered — it is real work,
// and refusing it here would refuse a directory the launch itself takes.
assert!(known.contains(&plain), "{known:?}");
}
/// A linked worktree is offered as itself, because it is a directory to
/// start an agent in — and it is where this gets subtle, since a worktree's
/// `.git` is a *file* naming a private git dir rather than a directory.
#[test]
fn the_directory_field_understands_a_linked_worktree() {
let root = tempfile::tempdir().expect("tempdir");
let repo = root.path().join("cctop");
std::fs::create_dir_all(repo.join(".git/worktrees/wip")).expect("gitdir");
std::fs::write(repo.join(".git/worktrees/wip/commondir"), "../..\n").expect("commondir");
std::fs::write(
repo.join(".git/worktrees/wip/HEAD"),
"ref: refs/heads/wip\n",
)
.expect("HEAD");
std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").expect("HEAD");
// What `git worktree add` leaves behind: a `.git` file, not a directory.
let tree = root.path().join("cctop-wip");
std::fs::create_dir_all(&tree).expect("worktree");
std::fs::write(
tree.join(".git"),
format!("gitdir: {}\n", repo.join(".git/worktrees/wip").display()),
)
.expect(".git file");
// And an agent one level inside it.
let deep = tree.join("src");
let mut app = test_app();
app.sessions = vec![session("a", false, &deep.to_string_lossy())];
app.launch_root = None;
app.launch_cwd = None;
let known = app.known_dirs();
assert_eq!(
known,
vec![tree],
"the worktree, not the main checkout and not the directory inside it"
);
}
/// A repository nobody has launched an agent in is offered anyway.
///
/// This is the case the scan exists for: cctop learns about projects from
/// the agents that ran in them, so a repository you have just pulled is
/// exactly the one it has no evidence for. And it is offered after the ones
/// an agent has run in — a project you worked in today is the likelier
/// answer, and an untried repository is a fallback.
#[test]
fn a_repository_with_no_agent_history_is_offered_after_the_ones_that_have_one() {
let root = tempfile::tempdir().expect("tempdir");
let worked = root.path().join("worked");
let fresh = root.path().join("fresh");
std::fs::create_dir_all(worked.join(".git")).expect("worked repo");
std::fs::create_dir_all(fresh.join(".git")).expect("fresh repo");
let mut app = test_app();
app.sessions = vec![session("a", false, &worked.to_string_lossy())];
app.launch_root = None;
app.launch_cwd = None;
app.launch_cwd_repos = vec![fresh.clone(), worked.clone()];
assert_eq!(
app.known_dirs(),
vec![worked, fresh],
"the one with history first, and the repository nobody has tried is offered at all"
);
}
/// A repository already offered because an agent ran in it is not offered
/// again as a discovery — the list would otherwise show the same path twice.
#[test]
fn a_repository_known_from_a_session_is_not_offered_again_by_the_scan() {
let root = tempfile::tempdir().expect("tempdir");
let repo = root.path().join("project");
std::fs::create_dir_all(repo.join(".git")).expect("repo");
let mut app = test_app();
app.sessions = vec![session("a", false, &repo.to_string_lossy())];
app.launch_root = None;
app.launch_cwd = None;
app.launch_cwd_repos = vec![repo.clone()];
assert_eq!(app.known_dirs(), vec![repo]);
}
/// The order is last used, then when the session began.
///
/// `last_active` is the useful half and it was already there; `started_at`
/// is what makes two sessions that were last touched at the same instant
/// come out in a fixed order rather than whatever the rows happened to be in
/// — which changes under a refresh, and a list that reshuffles while it is
/// being read is a list nobody can point at.
#[test]
fn the_directory_field_orders_by_last_used_then_by_when_the_session_began() {
let root = tempfile::tempdir().expect("tempdir");
let a = root.path().join("a");
let b = root.path().join("b");
let c = root.path().join("c");
for d in [&a, &b, &c] {
std::fs::create_dir(d).expect("dir");
}
let mut app = test_app();
// `a` and `b` were last touched at the same instant, and began an hour
// apart, so only `started_at` can say which was first.
let mut older = session("a", false, &a.to_string_lossy());
older.last_active = "2026-10-06T12:00:00Z".into();
older.started_at = "2026-10-06T09:00:00Z".into();
let mut newer = session("b", false, &b.to_string_lossy());
newer.last_active = "2026-10-06T12:00:00Z".into();
newer.started_at = "2026-10-06T11:00:00Z".into();
// Touched most recently of all, so it leads regardless of when it began.
let mut touched = session("c", false, &c.to_string_lossy());
touched.last_active = "2026-10-06T13:00:00Z".into();
touched.started_at = "2026-10-05T08:00:00Z".into();
app.sessions = vec![older, newer, touched];
app.launch_root = None;
app.launch_cwd = None;
assert_eq!(
app.known_dirs(),
vec![c, b, a],
"last used first, and the newer of two equally-touched sessions ahead"
);
}
/// Esc has to leave the launch as it was found, or it becomes a way to lose
/// the setting you opened the field to change.
#[test]
fn cancelling_the_directory_field_keeps_the_old_one() {
let dir = tempfile::tempdir().expect("tempdir");
let mut app = App::new(Plan::Retail, channel().0);
app.launch_cwd = Some(dir.path().to_path_buf());
app.edit_launch_cwd();
app.launch_cwd_input = "/somewhere/else".into();
app.on_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::Launch);
assert_eq!(app.launch_cwd.as_deref(), Some(dir.path()));
}
}