trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! ratatui drawing for the `tm ls` session TUI (#7224).
//!
//! Why: kept apart from the event loop so the frame can be rendered against a
//! `TestBackend` at any size — 20x5, 80x24, 200x50 — without a daemon, a
//! terminal, or a key loop. That is what turns "no panic at a tiny size" and
//! "narrow terminals drop columns" into tests rather than claims.
//!
//! What: [`render`] draws a title line, the session table (columns chosen by
//! [`super::layout::visible_columns`] from the CURRENT frame width), a status
//! line, and the key footer — then overlays the confirm / rename / help modal
//! when one is open. Every region comes from a `Layout`, which clamps to the
//! available area rather than computing a `Rect` that could fall outside it.
//!
//! Test: `render_*` in `super::tests`.

use ratatui::{
    Frame,
    layout::{Constraint, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, Paragraph, Row, Table, Wrap},
};
use trusty_mpm::client::ManagedSessionSummary;

use crate::commands::session_picker_render::{StateColor, session_color};

use super::layout::{self, Column, MARKER_WIDTH};
use super::state::{Mode, Severity, TuiState};

/// The footer key legend — the discoverability half of every action key.
const FOOTER: &str =
    "↑↓ move · Enter open · n new · r rename · d delete · R refresh · ? keys · q quit";

/// Draw one frame of the session TUI.
///
/// Why: one entry point so the modal overlay can never be drawn without the
/// list underneath it, and so the `TestBackend` tests exercise exactly what a
/// real terminal gets.
/// What: splits the area into title / table / status / footer, renders the
/// table for the columns that fit, records the scroll offset it used back into
/// `state` (so the next frame holds still), then overlays a modal for any mode
/// other than [`Mode::Browse`]. Takes `&mut TuiState` only for that scroll
/// write-back; nothing else about the state changes here.
/// Test: `render_at_eighty_by_twentyfour_drops_the_widest_columns`,
/// `render_at_two_hundred_by_fifty_shows_every_column`,
/// `render_tiny_terminal_does_not_panic`, `render_*_overlay_*`.
pub(crate) fn render(frame: &mut Frame, sessions: &[ManagedSessionSummary], state: &mut TuiState) {
    let area = frame.area();
    // #7224: the status region is sized from the wrapped message BEFORE the
    // split, so a multi-line refusal gets the rows it needs instead of being
    // clipped mid-token by a fixed one-row `Paragraph`. Capped so the table
    // keeps at least one row on a short terminal.
    let status_text = status_lines(state, area.width);
    let status_rows = (status_text.len().max(1) as u16)
        .min(area.height.saturating_sub(3).max(1))
        .max(1);
    let [title, body, status, footer] = Layout::vertical([
        Constraint::Length(1),
        Constraint::Min(1),
        Constraint::Length(status_rows),
        Constraint::Length(1),
    ])
    .areas(area);

    frame.render_widget(
        Paragraph::new(title_line(sessions, state))
            .style(Style::default().add_modifier(Modifier::BOLD)),
        title,
    );
    render_table(frame, body, sessions, state);
    frame.render_widget(Paragraph::new(status_text), status);
    frame.render_widget(
        Paragraph::new(FOOTER).style(Style::default().fg(Color::DarkGray)),
        footer,
    );

    match state.mode() {
        Mode::Browse => {}
        Mode::Help => overlay(frame, area, "keys", help_body()),
        Mode::Confirm {
            index,
            force,
            stop_first,
            typed,
        } => {
            let name = sessions.get(*index).map_or("?", |s| s.name.as_str());
            let body = confirm_body(name, *force, *stop_first, typed);
            overlay(frame, area, "delete", body);
        }
        Mode::Rename { index, typed } => {
            let was = sessions.get(*index).map_or("?", |s| s.name.as_str());
            overlay(frame, area, "rename", rename_body(was, typed));
        }
        // #7395: the create flow — a project list, then optionally a path.
        // #7406: built to the popup's INNER width (its rect less the two
        // border columns), so no project row can wrap.
        Mode::New(flow) => {
            let inner = overlay_rect(area).width.saturating_sub(2) as usize;
            overlay(frame, area, "new session", new_session_body(flow, inner));
        }
    }
}

/// The one-line header above the table.
fn title_line(sessions: &[ManagedSessionSummary], state: &TuiState) -> String {
    if sessions.is_empty() {
        return "tm ls — no sessions".to_string();
    }
    format!(
        "tm ls — {} of {} session(s)",
        state.selected() + 1,
        sessions.len()
    )
}

/// The status region: the last outcome, wrapped to `width`, or nothing.
///
/// Why: a refusal from the daemon can run past 150 characters, and the one that
/// matters most — the delete guard's — ends in a session UUID. Wrapping it
/// through [`layout::wrap_message`] rather than letting a one-row `Paragraph`
/// clip it is what keeps that id whole (#7224).
/// What: one styled [`Line`] per wrapped row, red for a refusal and green for an
/// outcome; an empty `Vec` when there is no message.
/// Test: `render_wraps_a_long_refusal_without_cutting_the_uuid`,
/// `render_status_uses_one_row_when_there_is_no_message`.
fn status_lines(state: &TuiState, width: u16) -> Vec<Line<'static>> {
    let Some((text, severity)) = state.message() else {
        return Vec::new();
    };
    let color = match severity {
        Severity::Error => Color::Red,
        Severity::Info => Color::Green,
    };
    layout::wrap_message(text, width as usize, layout::STATUS_MAX_LINES)
        .into_iter()
        .map(|line| Line::from(Span::styled(line, Style::default().fg(color))))
        .collect()
}

/// Render the session table into `area` for whatever columns fit its width.
///
/// Why: the column set has to be recomputed here rather than cached, because
/// `area` is whatever the terminal is RIGHT NOW — a resize is just a frame with
/// a different width, which is the whole of #7224's resize handling.
/// What: reserves one row for the header, computes the visible window through
/// [`layout::visible_window`], and writes the offset back into `state`.
fn render_table(
    frame: &mut Frame,
    area: Rect,
    sessions: &[ManagedSessionSummary],
    state: &mut TuiState,
) {
    if area.height == 0 || area.width == 0 {
        return;
    }
    let columns = layout::visible_columns(area.width);
    let rows_height = area.height.saturating_sub(1) as usize;
    let (start, len) = layout::visible_window(
        sessions.len(),
        state.selected(),
        rows_height,
        state.scroll(),
    );
    state.set_scroll(start);

    let rows: Vec<Row> = sessions[start..start + len]
        .iter()
        .enumerate()
        .map(|(offset, session)| {
            let selected = start + offset == state.selected();
            row(&columns, session, selected, state.use_color())
        })
        .collect();
    let header = Row::new(
        std::iter::once(String::new())
            .chain(columns.iter().map(|c| layout::header(*c).to_string()))
            .collect::<Vec<_>>(),
    )
    .style(Style::default().fg(Color::DarkGray));
    let widths: Vec<Constraint> = std::iter::once(Constraint::Length(MARKER_WIDTH))
        .chain(columns.iter().map(|c| match c {
            // NAME absorbs the leftover width so a wide terminal is not mostly
            // empty gutter; every other column keeps its fixed size.
            Column::Name => Constraint::Min(layout::width(Column::Name)),
            other => Constraint::Length(layout::width(*other)),
        }))
        .collect();
    frame.render_widget(
        Table::new(rows, widths).header(header).column_spacing(1),
        area,
    );
}

/// Build one table row, marked and highlighted when it is the selected one.
///
/// #8506: an unselected row takes its whole-row color from the shared
/// [`session_color`] mapping, and none at all when `use_color` is off.
fn row(
    columns: &[Column],
    session: &ManagedSessionSummary,
    selected: bool,
    use_color: bool,
) -> Row<'static> {
    let cells: Vec<String> = std::iter::once(if selected {
        "▸".to_string()
    } else {
        String::new()
    })
    .chain(columns.iter().map(|c| layout::cell(*c, session)))
    .collect();
    let style = if selected {
        Style::default()
            .fg(Color::Black)
            .bg(Color::Cyan)
            .add_modifier(Modifier::BOLD)
    } else if use_color {
        state_style(session_color(session))
    } else {
        Style::default()
    };
    Row::new(cells).style(style)
}

/// The ratatui style for a [`StateColor`] — the TUI's half of the shared
/// mapping, whose ANSI half is `session_picker_render::colorize`.
///
/// Test: `render_paints_each_row_in_its_state_color`.
pub(crate) fn state_style(color: StateColor) -> Style {
    let style = Style::default();
    match color {
        StateColor::Green => style.fg(Color::Green),
        StateColor::AttachedCyan => style.fg(Color::Cyan).add_modifier(Modifier::BOLD),
        StateColor::Dim => style.add_modifier(Modifier::DIM),
        StateColor::Red => style.fg(Color::Red),
        StateColor::Yellow => style.fg(Color::Yellow),
        StateColor::Blue => style.fg(Color::Blue),
        StateColor::Magenta => style.fg(Color::Magenta),
        StateColor::Cyan => style.fg(Color::Cyan),
        StateColor::Plain => style,
    }
}

/// Draw a centered modal with `title` over the list.
///
/// Why: the confirm and rename steps must be unmissable, and an overlay is the
/// only way to say "this key press means something different now" without
/// redrawing the whole surface.
/// What: a percentage-sized centered `Rect` (percentages cannot fall outside
/// the parent, which is what keeps a 20x5 terminal from panicking), `Clear`ed
/// then filled with a bordered paragraph.
fn overlay(frame: &mut Frame, area: Rect, title: &str, body: Vec<Line<'static>>) {
    let popup = overlay_rect(area);
    frame.render_widget(Clear, popup);
    frame.render_widget(
        Paragraph::new(body).wrap(Wrap { trim: false }).block(
            Block::default()
                .borders(Borders::ALL)
                .title(format!(" {title} ")),
        ),
        popup,
    );
}

/// The centered modal's rect: 60% of the height, 80% of the width.
///
/// #7406: split out of [`overlay`] so a body can be built to the width it will
/// actually be drawn at. Percentages cannot fall outside the parent, which is
/// what keeps a 20x5 terminal from panicking.
fn overlay_rect(area: Rect) -> Rect {
    let [_, middle, _] = Layout::vertical([
        Constraint::Percentage(20),
        Constraint::Percentage(60),
        Constraint::Percentage(20),
    ])
    .areas(area);
    let [_, popup, _] = Layout::horizontal([
        Constraint::Percentage(10),
        Constraint::Percentage(80),
        Constraint::Percentage(10),
    ])
    .areas(middle);
    popup
}

/// Text of `text` that fits `width` columns, ending in `…` when it was cut.
///
/// Why (#7406): the overlay wraps, so one row wider than the pane becomes two
/// rows and the list stops lining up. Cutting is the lesser loss — the head of
/// an `owner/repo` is what identifies it.
/// What: counts CHARACTERS, not bytes, so a multi-byte name is never split
/// mid-codepoint. A width under two leaves nothing to say and returns empty.
/// Test: `render_new_session_overlay_truncates_a_long_row`.
fn fit(text: &str, width: usize) -> String {
    if text.chars().count() <= width {
        return text.to_string();
    }
    if width < 2 {
        return String::new();
    }
    let mut cut: String = text.chars().take(width - 1).collect();
    cut.push('…');
    cut
}

/// The delete confirmation's text — the word required, and what typing it does.
///
/// `stop_first` (#7224) states the second leg up front: confirming an errored
/// row stops its runtime AND deletes the record, so the prompt must say so
/// before the operator agrees to it rather than after.
fn confirm_body(name: &str, force: bool, stop_first: bool, typed: &str) -> Vec<Line<'static>> {
    let ask = if force {
        format!("'{name}' is RUNNING. Type the word force, then Enter, to delete it.")
    } else if stop_first {
        crate::commands::picker_delete::errored_confirm_ask(name)
    } else {
        format!("Delete '{name}'? Type y, then Enter.")
    };
    vec![
        Line::from(ask),
        Line::from(String::new()),
        Line::from(format!("> {typed}▌")),
        Line::from("Esc cancels."),
    ]
}

/// The rename overlay's text.
fn rename_body(was: &str, typed: &str) -> Vec<Line<'static>> {
    vec![
        Line::from(format!("Rename '{was}' to:")),
        Line::from(String::new()),
        Line::from(format!("> {typed}▌")),
        Line::from("Enter applies, Esc cancels."),
    ]
}

/// The new-session overlay's text: the project list, or the path entry (#7395).
///
/// Why: one body function for both steps keeps "which step am I on" a single
/// readable branch, and makes the free-text entry look exactly like the rename
/// overlay's — the operator has already learned that shape.
/// What: the picker step lists the windowed targets from
/// [`super::new_session::NewSessionFlow::rows`], each cut to `width` (#7406),
/// under a hint line carrying the window's position in the list and the typed
/// filter (#7421); the path step draws the typed buffer with the same cursor
/// block. Both name Esc as the way out.
/// Test: `render_new_session_overlay_lists_the_registered_projects`,
/// `render_new_session_overlay_shows_the_typed_path`,
/// `render_new_session_overlay_truncates_a_long_row`,
/// `render_new_session_overlay_shows_the_position_and_filter`,
/// `new_session_name_overlay_shows_the_text_and_preview`.
fn new_session_body(flow: &super::new_session::NewSessionFlow, width: usize) -> Vec<Line<'static>> {
    // #8587: the Ctrl-N name step — the typed text and the name it slugs to.
    if let Some(naming) = flow.naming() {
        let preview = naming
            .preview()
            .unwrap_or_else(|| "(type a letter or digit)".to_string());
        return vec![
            Line::from(fit(
                &format!("Name the new session in {}:", naming.label()),
                width,
            )),
            Line::from(String::new()),
            Line::from(format!("> {}▌", naming.typed())),
            Line::from(fit(&format!("session: {preview}"), width)),
            Line::from("Enter creates it, Esc goes back to the list."),
        ];
    }
    if let Some(typed) = flow.typed() {
        return vec![
            // #7488: the entry takes a clone URL or owner/repo too, so the
            // prompt has to say so or the capability is invisible.
            Line::from("Path, clone URL or owner/repo tm does not have yet:"),
            Line::from(String::new()),
            Line::from(format!("> {typed}▌")),
            Line::from("Enter registers it and starts a session, Esc cancels."),
        ];
    }
    let mut lines = vec![
        Line::from("Start a new session in:"),
        Line::from(String::new()),
    ];
    lines.extend(flow.rows().iter().map(|r| Line::from(fit(r, width))));
    // #7421: without this the rows past the eighth simply looked absent.
    let hint = new_session_hint(flow);
    if !hint.is_empty() {
        lines.push(Line::from(fit(&hint, width)));
    }
    lines.push(Line::from(String::new()));
    // #7488: a filter matching nothing is an entry, not a dead end.
    lines.push(Line::from(
        "Type to filter, or type owner/repo or a clone URL.",
    ));
    // #8587: Ctrl-N is invisible unless the list says so.
    lines.push(Line::from(
        "Enter confirms, Ctrl-N names the session first, Esc cancels.",
    ));
    lines
}

/// The picker's position-and-filter hint, empty when it would say nothing.
///
/// A short list that has not been filtered has neither a position worth stating
/// nor a filter to echo, so it gets no line at all rather than a blank one.
fn new_session_hint(flow: &super::new_session::NewSessionFlow) -> String {
    let mut parts: Vec<String> = flow.position().into_iter().collect();
    if !flow.filter().is_empty() {
        parts.push(format!("filter: {}", flow.filter()));
    }
    parts.join("  ·  ")
}

/// The `?` key reference.
fn help_body() -> Vec<Line<'static>> {
    [
        "↑ / k, ↓ / j   move the selection",
        "g / G          first / last row",
        "PgUp / PgDn    move ten rows",
        "Enter          open (resume + attach) the selected session",
        "n              new session — pick a project, or type a path, a clone",
        "               URL, owner/repo, or domain/owner/repo",
        "Ctrl-N         in that project list: name the new session first",
        "r              rename it",
        "d              delete it, with a confirm step",
        "R              refresh the list from the daemon",
        "q / Esc        quit",
        "",
        "Any key closes this help.",
    ]
    .into_iter()
    .map(|l| Line::from(l.to_string()))
    .collect()
}