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
//! `tm ls` — the full-screen, resize-aware managed-session TUI (#7224).
//!
//! Why: the numbered picker this replaces on the `tm ls` path computed its
//! column widths once, from the terminal width at startup, and read whole lines
//! from stdin. A resize left it formatted for a terminal that no longer
//! existed, and every action was a typed verb (`d2`, `r3 new-name`) an operator
//! had to remember. #6752 proposed reflowing that same line-based menu at a
//! fixed 80 columns; this supersedes that surface instead — a redrawn frame is
//! resize handling for free, and a selected row makes the verbs into single
//! keys.
//!
//! What this REUSES, and what it does not. Every action routes through the path
//! the CLI already has: Enter calls
//! [`guided_resume::resume_guided_session`](super::guided_resume::resume_guided_session)
//! — the same function the numbered picker's `Resume` arm and `tm session
//! resume <id>` reach; `d` calls
//! [`picker_delete::delete_managed_then_local`](super::picker_delete::delete_managed_then_local),
//! the shared managed→local routing `tm session delete` uses, after a confirm
//! step that reuses that module's own `confirm_is_yes`/`confirm_is_force`
//! classifiers; `r` calls
//! [`rename::do_rename_request`](super::rename::do_rename_request), the exact
//! PATCH `tm sessions rename` issues, after the daemon's own
//! `validate_session_name`; `n` (#7395) is [`new_session`], which reads the
//! project registry through the same `GET /api/v1/projects` `tm project list`
//! uses, registers an unregistered path through
//! [`projects::registry::register`](super::projects::registry::register), and
//! creates the session through
//! [`guided_launch::launch_new_session_and_attach`](super::guided_launch::launch_new_session_and_attach)
//! — the picker's own launch-new call. The terminal setup, the panic-safe guard, and the
//! suspend/resume around the tmux hand-off come from
//! [`trusty_mpm::tui::terminal`], shared with the coordinator and `project_ctl`
//! screens. What is NOT shared is the input loop: a full-screen surface needs
//! raw-mode keys, and the numbered picker keeps its line reader for the cases
//! this surface cannot serve — an empty fleet (only the picker can launch a
//! NEW session) and a terminal that cannot enter raw mode.
//!
//! #7224: bare `tm` opens this surface too, through
//! [`super::session_ls_connector::run_bare_tm_surface`] — the same gate, not a
//! second copy of it.
//!
//! `tm ls --plain`, `--json`, `--all`, `--attached`, and any non-TTY stream
//! still print the static table, unchanged — see
//! [`super::session_ls_connector::should_show_picker`].
//!
//! Test: the pure halves are unit-tested in `session_tui/tests.rs` — the state
//! machine, the width-driven column layout, the scroll window, and
//! `TestBackend` renders at 80x24, 200x50, and 20x5. The daemon round trips are
//! the ones the reused functions already carry tests for; the terminal
//! hand-off is verified by the tmux smoke run in the PR body.
pub(crate) mod layout;
pub(crate) mod new_session;
pub(crate) mod new_session_entry;
pub(crate) mod new_session_name;
pub(crate) mod new_session_order;
pub(crate) mod render;
pub(crate) mod state;
#[cfg(test)]
#[path = "session_tui/tests.rs"]
mod tests;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use trusty_mpm::client::ManagedSessionSummary;
use trusty_mpm::tui::terminal::{self, TerminalGuard};
use super::picker_delete::{DeleteReport, route_delete};
use super::session_picker::{PickerScope, fetch_live_sessions};
use state::{Action, Input, Severity, TuiState};
/// Map one `crossterm` key event onto the surface's [`Input`] alphabet.
///
/// Why: a pure boundary between "what crossterm reported" and "what this
/// surface understands" is what lets every transition be tested without a
/// terminal. Characters pass through unresolved on purpose — `d` is delete
/// while browsing and a literal `d` while typing a name, and only
/// [`TuiState::apply`] knows which mode is open.
/// What: `None` for a key with no meaning here (a release event, a function
/// key, a Ctrl chord other than C/D/N), so the caller can skip the redraw.
/// #8587: Ctrl-N (either case, so Ctrl-Shift-N too) is [`Input::NameNew`].
/// Test: `input_maps_ctrl_c_to_cancel`, `input_maps_ctrl_n_to_name_new`,
/// `input_ignores_key_release`, `input_passes_characters_through_unresolved`.
pub(crate) fn map_key(key: KeyEvent) -> Option<Input> {
// Windows reports both press and release; act on press only.
if key.kind == KeyEventKind::Release {
return None;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c' | 'd') if ctrl => Some(Input::Cancel),
KeyCode::Char('n' | 'N') if ctrl => Some(Input::NameNew),
KeyCode::Char(_) if ctrl => None,
KeyCode::Char(c) => Some(Input::Char(c)),
KeyCode::Enter => Some(Input::Enter),
KeyCode::Backspace => Some(Input::Backspace),
KeyCode::Esc => Some(Input::Escape),
KeyCode::Up => Some(Input::Up),
KeyCode::Down => Some(Input::Down),
KeyCode::Home => Some(Input::Home),
KeyCode::End => Some(Input::End),
KeyCode::PageUp => Some(Input::PageUp),
KeyCode::PageDown => Some(Input::PageDown),
_ => None,
}
}
/// Run the `tm ls` session TUI until the operator quits or hands the terminal off.
///
/// Why: one entry point owns the terminal lifecycle, so the alternate screen is
/// always left before a tmux attach and always restored afterwards — including
/// on a panic, via [`TerminalGuard`].
/// What: loops — order the list through
/// [`prepare_menu`](super::session_picker_order::prepare_menu) (the same
/// sort/filter/pin the numbered picker uses, so the two surfaces cannot show
/// different orders), draw, read one key, and dispatch the [`Action`] the state
/// machine returns. `Open` suspends the terminal, calls the shared resume path,
/// and either returns (a `switch-client` hand-off left this pane invisible —
/// #2678) or resumes the TUI and re-fetches. `Delete` and `Rename` are HTTP
/// round trips that report into the status line and re-fetch; neither leaves
/// the alternate screen.
///
/// `self_session_id` is `$TM_MANAGED_SESSION_ID` and `self_tmux_name` the tmux
/// session this process runs inside — both injected rather than read here,
/// because `src/bin/tm/**` is ratcheted against process-global env access
/// (#5544). Together they are how the delete guard recognises the session the
/// operator is attached to.
/// Test: the pure halves in `session_tui/tests.rs`; the loop itself by the tmux
/// smoke run.
pub(crate) async fn run_session_tui(
client: &reqwest::Client,
url: &str,
scope: &mut PickerScope,
sessions: Vec<ManagedSessionSummary>,
self_session_id: Option<String>,
self_tmux_name: Option<String>,
) -> anyhow::Result<()> {
let mut sessions = sessions;
// #8506: the same stdout TTY + `NO_COLOR` gate the static table uses.
let use_color = super::session_picker_render::table_use_color(
std::io::IsTerminal::is_terminal(&std::io::stdout()),
);
let mut state = TuiState::new(self_session_id, self_tmux_name).with_color(use_color);
let mut terminal = terminal::enter()?;
// The guard restores cooked mode and the main screen on every exit path —
// normal return AND panic unwind — so it is the SOLE teardown.
let _guard = TerminalGuard;
loop {
// #7224: the ORDER comes from the numbered picker's own `prepare_menu`,
// so `tm ls` shows one ordering whichever surface renders it.
let menu = super::session_picker_order::prepare_menu(sessions, scope);
sessions = menu.sessions;
state.sync(&sessions);
scope.selected_id = sessions.get(state.selected()).map(|s| s.id.clone());
terminal.draw(|frame| render::render(frame, &sessions, &mut state))?;
// A `Resize` arrives as an ordinary event and simply falls through to
// the next draw, which recomputes the column set from the new width —
// that is the whole of the resize handling (#7224 requirement 2).
let Event::Key(key) = event::read()? else {
continue;
};
let Some(input) = map_key(key) else { continue };
match state.apply(input, &sessions) {
Action::Ignore | Action::Redraw => continue,
Action::Quit => break,
Action::Refresh => {
state.set_message("refreshed", Severity::Info);
}
// #7395: read the project registry, then open the create flow over
// it. `continue` rather than falling through — opening an overlay
// changed nothing about the fleet, so it owes no re-fetch.
// #7421: the session list this loop already holds is what orders
// the picker's rows, so no second daemon read is made for it.
Action::NewSession => match new_session::fetch_targets(client, url, &sessions).await {
Ok(targets) => {
// #7406: open on the project of the row the cursor is on.
let cursor = sessions.get(state.selected());
state.open_new_session(
new_session::NewSessionFlow::new(targets).preselected_for(cursor),
);
continue;
}
Err(e) => state.set_message(
format!("could not list registered projects: {e}"),
Severity::Error,
),
},
// #7395: register (when the path was new) then create and attach,
// through the same functions `tm projects register` and the
// numbered picker's launch-new use. The alternate screen goes away
// first, exactly as it does for `Open` — both end in a tmux
// hand-off that needs the real terminal in cooked mode.
Action::Create(request) => {
// #8587: names the session when Ctrl-N named it.
let created = new_session_name::created_message(&request);
terminal::suspend(&mut terminal)?;
match new_session::perform(client, url, request).await {
// #2678: the hand-off moved the operator's client away.
Ok(outcome) if outcome.ends_interactive_loop() => return Ok(()),
Ok(_) => {
terminal::resume(&mut terminal)?;
state.set_message(created, Severity::Info);
}
Err(e) => {
terminal::resume(&mut terminal)?;
state.set_message(format!("new session failed: {e}"), Severity::Error);
}
}
}
Action::Open(index) => {
// #7224 requirement 3: the alternate screen goes away BEFORE
// the tmux hand-off, so `attach-session` gets the real terminal
// in cooked mode.
terminal::suspend(&mut terminal)?;
let outcome = super::guided_resume::resume_guided_session(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
&sessions[index],
)
.await;
match outcome {
// #2678: a `switch-client` hand-off (or a fail-closed skip)
// means this pane is no longer visible — stop rather than
// redrawing onto a screen nobody is looking at.
Ok(outcome) if outcome.ends_interactive_loop() => return Ok(()),
Ok(_) => terminal::resume(&mut terminal)?,
Err(e) => {
terminal::resume(&mut terminal)?;
state.set_message(format!("open failed: {e}"), Severity::Error);
}
}
}
Action::Delete {
index,
force,
stop_first,
} => {
let session = &sessions[index];
// #7224: an errored row takes the stop-then-delete route, which
// is the CLI step the daemon's refusal used to send the operator
// out of this surface to run by hand. The numbered fallback
// picker shares this routing rather than re-deciding it.
let report = route_delete(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
&session.id,
force,
stop_first,
)
.await;
let (text, severity) = delete_outcome(&session.name, report);
state.set_message(text, severity);
}
Action::Rename { index, name } => {
let session = &sessions[index];
match super::rename::do_rename_request(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
&session.name,
&session.id,
name,
)
.await
{
Ok(msg) => state.set_message(msg, Severity::Info),
Err(e) => state.set_message(format!("rename failed: {e}"), Severity::Error),
}
}
}
// Every dispatched action can have changed the fleet; re-fetch so the
// next frame is the daemon's answer, not this process's guess. A fetch
// failure keeps the list already in hand and says so, rather than
// dropping the operator back to a shell.
match fetch_live_sessions(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
scope.source_id.as_deref(),
false,
)
.await
{
Ok(fetched) => sessions = fetched,
Err(e) => state.set_message(format!("refresh failed: {e}"), Severity::Error),
}
}
Ok(())
}
/// Turn a [`DeleteReport`] into the status line the TUI shows.
///
/// Why: a pure function so the wording — including the soft-delete caveat that
/// a managed record stays listed — is assertable without a daemon. The line
/// picker prints the same three outcomes; keeping this separate is what lets
/// the TUI say them in one line instead of two.
/// What: `Deleted` reports the store it came from (a managed record is SOFT
/// deleted and stays in the listing, #2012); `Refused` carries the daemon's own
/// 409 text; `StopFailed` (#7224) says the stop leg failed and NOTHING was
/// deleted, carrying the daemon's full status and body; `NotFound` says the
/// record was already gone. A transport error becomes an error line rather than
/// ending the TUI. #8935: a daemon note that a live tmux session with the
/// name was left running is appended.
/// Test: `delete_outcome_names_the_soft_delete`,
/// `delete_outcome_says_a_live_session_was_left_running`,
/// `delete_outcome_surfaces_a_refusal`,
/// `delete_outcome_reports_a_failed_stop_as_not_deleted`,
/// `delete_outcome_reports_not_found`,
/// `delete_outcome_reports_a_transport_error`.
pub(crate) fn delete_outcome(
name: &str,
report: anyhow::Result<DeleteReport>,
) -> (String, Severity) {
match report {
Ok(DeleteReport::Deleted {
name,
prior_state,
local: true,
..
}) => (
format!("deleted '{name}' [was {prior_state}] from the project store"),
Severity::Info,
),
Ok(DeleteReport::Deleted {
name,
prior_state,
note,
..
}) => (
format!(
"'{name}' [was {prior_state}] marked --deleted-- \
(still listed; `tm sessions prune --state deleted` removes it){}",
// #8935: delete is record-only; say a live session was kept.
note.map(|n| format!(" — record only: {n}"))
.unwrap_or_default()
),
Severity::Info,
),
Ok(DeleteReport::Refused(msg)) => {
(format!("delete refused: {}", msg.trim()), Severity::Error)
}
Ok(DeleteReport::StopFailed(msg)) => (
format!("'{name}' NOT deleted — {}", msg.trim()),
Severity::Error,
),
Ok(DeleteReport::NotFound) => {
(format!("'{name}' not found — already gone"), Severity::Info)
}
Err(e) => (format!("delete failed: {e}"), Severity::Error),
}
}