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
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
//! Coordinator TUI application state (Child #1 skeleton).
//!
//! Why: the render loop and the key dispatcher both read and mutate one piece
//! of state — the input buffer, an input-history ring, the placeholder session
//! rows, and which row is selected. Holding it in a single struct with pure,
//! unit-testable mutators keeps the terminal glue thin and the selection /
//! history logic verifiable without a terminal (DOC-13 §9).
//! What: [`CoordinatorState`] plus its placeholder [`SessionEntry`] rows, the
//! input-history ring, and [`CoordinatorState::clamp_selection`] /
//! selection-movement helpers that mirror `DashboardState::clamp_selection`.
//! Test: `super::tests` covers `clamp_selection` boundary cases (empty list,
//! single row, selection past end) and the history ring.

use ratatui::text::Line;

use super::nav::ListNav;

/// Maximum number of output-log lines retained for the scrollback pane.
///
/// Why: the coordinator echoes submitted lines and rendered command results into
/// an output log; bounding it keeps memory flat over a long session (mirrors the
/// input-history ring's bound).
/// What: the cap [`CoordinatorState::push_output`] enforces by dropping the
/// oldest lines beyond it.
/// Test: `output_log_is_bounded`.
pub const OUTPUT_LOG_LIMIT: usize = 500;

/// Maximum number of submitted inputs retained in the history ring.
///
/// Why: the input box recalls prior submissions with ↑/↓ (like the dashboard's
/// `CommandBar`); bounding the ring keeps memory flat over a long session.
/// What: the cap used by [`CoordinatorState::submit_input`].
/// Test: `history_ring_is_bounded`.
pub const INPUT_HISTORY_LIMIT: usize = 50;

/// One placeholder session row shown beneath the controller bullet.
///
/// Why: Child #1 renders static rows so the layout, selection, and two-column
/// active row can be exercised before the live feed lands (Child #2). Modelling
/// the row now — id, routing prefix, status, placeholder summary — means the
/// live feed later fills the *same* shape rather than reshaping the UI.
/// What: a managed session's short id, its `@prefix:` routing prefix, a
/// one-word status, and a placeholder summary for the active-row right column.
/// Test: rows are consumed by the row-builders, themselves unit-tested.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionEntry {
    /// Full session id (UUID string) used to route commands at this session.
    ///
    /// Why: free-text and target-less slash commands route at the *focused*
    /// session; the executor resolves a target by exact id/name/prefix against
    /// the live managed list, so carrying the canonical id (not just the 8-hex
    /// [`Self::short_id`]) makes that routing unambiguous.
    /// What: the full UUID from the daemon's coordinator context.
    /// Test: `focused_session_target_prefers_full_id` in `super::tests`.
    pub id: String,
    /// Short (8-hex) session id shown in the list's left column.
    pub short_id: String,
    /// Routing prefix (`aipowerranking`) shown after the id.
    pub prefix: String,
    /// One-word status word (`Active`, `Idle`, …) shown dimmed.
    pub status: String,
    /// Placeholder "last summarized message" for the active-row right column.
    ///
    /// Replaced by the daemon `last_summary` in Child #3.
    pub summary: String,
}

impl SessionEntry {
    /// Construct a placeholder session entry whose `id` mirrors its `short_id`.
    ///
    /// Why: the skeleton and the existing tests seed rows by short id; keeping
    /// this four-arg constructor (id == short_id) avoids churning every call
    /// site while [`Self::with_id`] is the precise form the live poll uses.
    /// What: moves the four owned strings into a [`SessionEntry`], reusing the
    /// short id as the routing id.
    /// Test: indirectly via `default_state_has_placeholder_rows`.
    pub fn new(
        short_id: impl Into<String>,
        prefix: impl Into<String>,
        status: impl Into<String>,
        summary: impl Into<String>,
    ) -> Self {
        let short_id = short_id.into();
        Self::with_id(short_id.clone(), short_id, prefix, status, summary)
    }

    /// Construct a session entry carrying both the full and short ids.
    ///
    /// Why: the live poll knows the session's full UUID (needed for routing) and
    /// derives the 8-hex display id; the two differ, so the constructor takes
    /// both rather than re-deriving one from the other at the call site.
    /// What: moves the five owned strings into a [`SessionEntry`].
    /// Test: `coord_session_to_entry_derives_short_id` (id carried through).
    pub fn with_id(
        id: impl Into<String>,
        short_id: impl Into<String>,
        prefix: impl Into<String>,
        status: impl Into<String>,
        summary: impl Into<String>,
    ) -> Self {
        Self {
            id: id.into(),
            short_id: short_id.into(),
            prefix: prefix.into(),
            status: status.into(),
            summary: summary.into(),
        }
    }
}

/// Everything the coordinator TUI renders and mutates this frame (skeleton).
///
/// Why: a single owned struct (like the dashboard's `DashboardState`) keeps the
/// data/render split clean — the event loop mutates it, the layout reads it.
/// What: the input buffer + history ring, the placeholder session rows, the
/// selected row index (`0` = controller), and an `should_exit` flag the loop
/// polls to leave on `q` / Ctrl-C.
/// Test: `super::tests` covers selection clamp, movement, and history.
#[derive(Debug, Clone, Default)]
pub struct CoordinatorState {
    /// The current input-box edit buffer.
    pub input: String,
    /// Submitted inputs, newest last, bounded by [`INPUT_HISTORY_LIMIT`].
    pub history: Vec<String>,
    /// Cursor into [`Self::history`] while recalling with ↑/↓; `None` = live.
    history_cursor: Option<usize>,
    /// Placeholder managed-session rows (Child #2 swaps in the live feed).
    pub sessions: Vec<SessionEntry>,
    /// Numbered-list navigation: the selected row index AND the scroll offset,
    /// preserved across the timer re-polls (STUI-1).
    ///
    /// Why: STUI-1 (#1278) replaces the old bare `selected: usize` with a
    /// [`ListState`](ratatui::widgets::ListState)-backed model so the list scrolls
    /// (≥ 5 visible rows), supports Page-Up/Page-Down, and — critically —
    /// preserves the selection + scroll offset across the timer re-polls Child #2
    /// introduced. Held as a [`ListNav`] so the render path can hand the wrapped
    /// `ListState` straight to `render_stateful_widget`.
    /// What: row 0 is the controller; sessions follow. Movement and clamping go
    /// through [`Self::select_up`] / [`Self::select_down`] / [`Self::page_up`] /
    /// [`Self::page_down`] / [`Self::clamp_selection`], which delegate to `nav`.
    /// Test: `nav_*` and `clamp_selection_*` in `super::tests`.
    pub nav: ListNav,
    /// Set when a list-mutating action (create/kill/stop/resume) just succeeded
    /// and the operator should see the fleet refreshed without waiting for the
    /// next timer tick (STUI-1 AC: "immediate re-poll after a mutation").
    ///
    /// Why: a poll only happens on the `--interval-ms` cadence; after the
    /// operator changes the fleet they expect the list to reflect it now, not up
    /// to a whole interval later. The run loop reads this flag each tick and, when
    /// set, re-polls immediately and resets its interval timer. The flag is the
    /// seam the (still-stubbed) slash-command dispatch will pull once #1278's
    /// sibling STUI-6 wires mutations.
    /// What: set by [`Self::request_repoll`]; consumed (read-and-cleared) by
    /// [`Self::take_repoll`].
    /// Test: `request_repoll_sets_and_take_clears`.
    needs_repoll: bool,
    /// Whether the daemon answered its last health probe.
    ///
    /// Why: Child #2 polls the coordinator-context endpoint on a timer and must
    /// surface daemon-down to the operator (a `daemon ✗` status indicator) and
    /// clear the session list rather than showing stale rows. Mirrors
    /// `DashboardState::daemon_reachable`. Defaults to `false` so the TUI reads
    /// "unreachable" until the priming poll confirms otherwise.
    /// What: set by [`super::poll::coord_poll_daemon`] after each health probe.
    /// Test: `poll_marks_unreachable_clears_sessions` in `super::tests` covers the
    /// daemon-down flip to `false`; the daemon-up flip needs a live daemon and is
    /// exercised manually.
    pub daemon_reachable: bool,
    /// Whether the daemon reports the deployed catalog content is stale (HR-3).
    ///
    /// Why: DOC-17 §HR-3 requires the operator surface to show that an update is
    /// available so they can rebuild. The coordinator poll reads the additive
    /// `catalog_stale` field off `GET /health` and the status bar renders a small
    /// `catalog: ↑updates available` hint when it is set. Defaults to `false`
    /// (no update) until the first health poll reports otherwise.
    /// What: set by [`super::poll::coord_poll_daemon`] from the health probe.
    /// Test: `poll_marks_unreachable_clears_sessions` confirms it stays `false`
    /// on a dead daemon; the rendered hint is covered by
    /// `catalog_indicator_reflects_staleness` in `super::tests`.
    pub catalog_stale: bool,
    /// Set when the operator asks to quit; the event loop exits on the next tick.
    pub should_exit: bool,
    /// The output/scrollback log: submitted lines and rendered command results.
    ///
    /// Why: Phase 1C dispatches slash commands and free-text through the chat-core
    /// executor and must SHOW the structured result (DOC-13 §5.2). The old stub
    /// echoed submissions into the recall ring only; this is the visible log the
    /// layout's output pane renders, holding the operator's echoed input and the
    /// styled lines [`super::render::render_result`] produces.
    /// What: a bounded ([`OUTPUT_LOG_LIMIT`]) vec of styled lines, oldest first.
    /// Appended via [`Self::push_output`] / [`Self::push_output_lines`].
    /// Test: `output_log_records_and_is_bounded`.
    pub output: Vec<Line<'static>>,
}

impl CoordinatorState {
    /// Build the skeleton state with a couple of illustrative placeholder rows.
    ///
    /// Why: Child #1 has no daemon feed, but an empty list would make the
    /// layout, selection movement, and two-column active row impossible to see
    /// or test. Seeding static rows exercises the full UI path; Child #2
    /// replaces them with `poll_daemon` output.
    /// What: returns a [`CoordinatorState`] with two placeholder sessions and
    /// the controller selected (row 0).
    /// Test: `default_state_has_placeholder_rows`.
    pub fn skeleton() -> Self {
        Self {
            sessions: vec![
                SessionEntry::new(
                    "4f9ca1b2",
                    "aipowerranking",
                    "Active",
                    "Running tests — 12 passed, fixing flaky timeout",
                ),
                SessionEntry::new(
                    "7b2ec0d1",
                    "genealogy",
                    "Idle",
                    "Idle — last activity 6m ago",
                ),
            ],
            ..Self::default()
        }
    }

    /// Build the live starting state: no rows, daemon presumed unreachable.
    ///
    /// Why: Child #2 drives the list from the daemon, not from the placeholder
    /// rows [`Self::skeleton`] seeds. The run loop starts here and the priming
    /// poll fills `sessions` + flips `daemon_reachable` once the context lands;
    /// starting empty (rather than with placeholders) means a single transient
    /// daemon outage never leaves stale illustrative rows on screen.
    /// What: returns [`CoordinatorState::default`] — empty input/history, no
    /// sessions, controller (row 0) selected, `daemon_reachable = false`.
    /// Test: `live_state_starts_empty_and_unreachable`.
    pub fn live() -> Self {
        Self::default()
    }

    /// Total number of selectable rows: the controller plus every session.
    ///
    /// Why: selection spans a unified list whose row 0 is always the controller
    /// bullet, so bounds checks must account for that synthetic first row.
    /// What: `1 + sessions.len()`.
    /// Test: `clamp_selection_*` rely on this count implicitly.
    pub fn row_count(&self) -> usize {
        1 + self.sessions.len()
    }

    /// The selected row index across the unified list (`0` = controller bullet).
    ///
    /// Why: callers that used to read the old `selected: usize` field (the
    /// renderer's active-row branch, `selected_session`) need the index; routing
    /// them through one accessor keeps the [`ListNav`] the single source of truth.
    /// What: returns [`ListNav::selected`].
    /// Test: `selection_movement_saturates`, `nav_default_selects_controller_row`.
    pub fn selected(&self) -> usize {
        self.nav.selected()
    }

    /// Focus a specific row by index, clamped into `0..row_count`.
    ///
    /// Why: STUI-1 lets the operator jump straight to a numbered session (and the
    /// post-mutation / focus paths need a way to set the selection directly rather
    /// than stepping it). Routing through one clamped setter keeps every selection
    /// change inside the list bounds.
    /// What: re-syncs `nav` to the current row count (so the bound is live) then
    /// selects `min(row, row_count - 1)`.
    /// Test: `select_row_clamps_into_bounds`.
    pub fn select_row(&mut self, row: usize) {
        self.nav.sync_len(self.row_count());
        self.nav.select(row);
    }

    /// Re-clamp the selection + scroll offset into `0..row_count` after a poll.
    ///
    /// Why: the live list shrinks between polls (sessions end); a stale index
    /// would render — and later index — out of bounds. STUI-1 additionally
    /// requires the selection AND scroll offset to be PRESERVED across a refresh
    /// when still valid, so this delegates to [`ListNav::sync_len`] rather than a
    /// bare index pin: a grown or unchanged list keeps the cursor exactly where
    /// the operator left it; only a shrink clamps inward.
    /// What: calls `self.nav.sync_len(self.row_count())`. `row_count` is always
    /// ≥ 1 because the controller row always exists, so the index never
    /// underflows.
    /// Test: `clamp_selection_empty_list`, `clamp_selection_single_row`,
    /// `clamp_selection_past_end`, `clamp_selection_preserves_valid_selection`.
    pub fn clamp_selection(&mut self) {
        self.nav.sync_len(self.row_count());
    }

    /// Move the selection up one row (saturating at the controller, row 0).
    ///
    /// Why: ↑ / `k` move the highlight toward the controller.
    /// What: delegates to [`ListNav::up`].
    /// Test: `selection_movement_saturates`.
    pub fn select_up(&mut self) {
        self.nav.up();
    }

    /// Move the selection down one row (saturating at the last session).
    ///
    /// Why: ↓ / `j` move the highlight toward the newest session.
    /// What: re-syncs the row count (so `nav` knows the live bound) then
    /// delegates to [`ListNav::down`].
    /// Test: `selection_movement_saturates`.
    pub fn select_down(&mut self) {
        self.nav.sync_len(self.row_count());
        self.nav.down();
    }

    /// Jump the selection up by one page (saturating at the controller, row 0).
    ///
    /// Why: Page-Up traverses a long fleet quickly (STUI-1 AC).
    /// What: delegates to [`ListNav::page_up`].
    /// Test: `page_scroll_moves_by_page_and_saturates`.
    pub fn page_up(&mut self) {
        self.nav.page_up();
    }

    /// Jump the selection down by one page (saturating at the last session).
    ///
    /// Why: Page-Down is the symmetric fast-scroll toward the newest session.
    /// What: re-syncs the row count then delegates to [`ListNav::page_down`].
    /// Test: `page_scroll_moves_by_page_and_saturates`.
    pub fn page_down(&mut self) {
        self.nav.sync_len(self.row_count());
        self.nav.page_down();
    }

    /// True when the controller bullet (row 0) is the active selection.
    ///
    /// Why: the layout renders the controller's right column as a synthetic
    /// status and a session's as its summary; the renderer branches on this.
    /// What: `self.selected() == 0`.
    /// Test: `controller_is_selected_at_row_zero`.
    pub fn controller_selected(&self) -> bool {
        self.selected() == 0
    }

    /// The session under the current selection, if a session (not the
    /// controller) is selected.
    ///
    /// Why: the active-row two-column renderer and future focus/action paths
    /// need the selected session; row 0 maps to the controller, so session
    /// indices are offset by one.
    /// What: returns `sessions[selected - 1]` when `selected > 0`, else `None`.
    /// Test: `selected_session_offsets_by_controller_row`.
    pub fn selected_session(&self) -> Option<&SessionEntry> {
        self.selected()
            .checked_sub(1)
            .and_then(|idx| self.sessions.get(idx))
    }

    /// The routing target of the focused session, if a session (not the
    /// controller) is selected.
    ///
    /// Why: free-text and target-less slash commands route at the *focused*
    /// session. "Focused" is the existing selection/highlight state: row 0 is the
    /// controller (no target), any other row is a session. The executor resolves a
    /// target by exact id, so this returns the full session id rather than the
    /// short display id.
    /// What: returns `Some(id)` of [`Self::selected_session`] when a session is
    /// selected, else `None` (controller selected → no focused session).
    /// Test: `focused_target_is_none_on_controller`,
    /// `focused_target_returns_selected_session_id`.
    pub fn focused_target(&self) -> Option<&str> {
        self.selected_session().map(|s| s.id.as_str())
    }

    /// Append one already-styled line to the output log (bounded).
    ///
    /// Why: the dispatch path echoes the operator's input and appends rendered
    /// results; a single bounded appender keeps the log from growing without limit.
    /// What: pushes `line`, dropping the oldest lines beyond [`OUTPUT_LOG_LIMIT`].
    /// Test: `output_log_records_and_is_bounded`.
    pub fn push_output(&mut self, line: Line<'static>) {
        self.output.push(line);
        self.trim_output();
    }

    /// Append several styled lines to the output log (bounded).
    ///
    /// Why: a rendered [`crate::client::CommandResult`] is multiple lines; appending
    /// them in one call keeps the trim-to-bound check off the per-line hot path.
    /// What: extends the log with `lines`, then trims to [`OUTPUT_LOG_LIMIT`].
    /// Test: `output_log_records_and_is_bounded`.
    pub fn push_output_lines(&mut self, lines: impl IntoIterator<Item = Line<'static>>) {
        self.output.extend(lines);
        self.trim_output();
    }

    /// Drop the oldest output lines beyond [`OUTPUT_LOG_LIMIT`].
    ///
    /// Why: keeps the scrollback bounded; factored out so both appenders share it.
    /// What: drains the leading overflow when the log exceeds the cap.
    /// Test: `output_log_records_and_is_bounded`.
    fn trim_output(&mut self) {
        if self.output.len() > OUTPUT_LOG_LIMIT {
            let overflow = self.output.len() - OUTPUT_LOG_LIMIT;
            self.output.drain(0..overflow);
        }
    }

    /// Request an immediate daemon re-poll on the next run-loop tick (STUI-1).
    ///
    /// Why: after a list-mutating action (create/kill/stop/resume) the operator
    /// expects the fleet to refresh now, not up to a whole `--interval-ms` later.
    /// Setting a flag — rather than polling inline — keeps the mutation path free
    /// of async/IO and lets the single run-loop own all daemon calls.
    /// What: sets the private `needs_repoll` flag.
    /// Test: `request_repoll_sets_and_take_clears`.
    pub fn request_repoll(&mut self) {
        self.needs_repoll = true;
    }

    /// Read-and-clear the immediate-re-poll request.
    ///
    /// Why: the run loop checks this each tick; a take-style consume guarantees a
    /// single requested re-poll fires exactly once and is not re-triggered every
    /// subsequent tick.
    /// What: returns the current `needs_repoll` value and resets it to `false`.
    /// Test: `request_repoll_sets_and_take_clears`.
    pub fn take_repoll(&mut self) -> bool {
        std::mem::take(&mut self.needs_repoll)
    }

    /// Append a character to the input buffer (a printable keystroke).
    ///
    /// Why: typing builds the coordinator message; editing resets the recall
    /// cursor so the next ↑ starts from the newest history entry.
    /// What: pushes `c`; clears the history cursor.
    /// Test: `input_edits_buffer`.
    pub fn push_char(&mut self, c: char) {
        self.input.push(c);
        self.history_cursor = None;
    }

    /// Delete the last input character (Backspace).
    ///
    /// Why: Backspace edits a mistyped message.
    /// What: pops the trailing char; clears the recall cursor.
    /// Test: `input_edits_buffer`.
    pub fn backspace(&mut self) {
        self.input.pop();
        self.history_cursor = None;
    }

    /// Clear the input buffer (Esc).
    ///
    /// Why: Esc abandons a half-typed message.
    /// What: empties [`Self::input`]; clears the recall cursor.
    /// Test: `input_edits_buffer`.
    pub fn clear_input(&mut self) {
        self.input.clear();
        self.history_cursor = None;
    }

    /// Submit the current input: record it in history and return it, clearing
    /// the buffer.
    ///
    /// Why: Enter submits. Child #1 only *echoes* the submission to history (no
    /// daemon wiring yet); later children route it to the coordinator chat.
    /// What: trims the buffer; when non-empty, pushes it onto the bounded
    /// history ring (dropping the oldest beyond [`INPUT_HISTORY_LIMIT`]) and
    /// returns `Some(text)`; an empty/whitespace buffer returns `None`. Always
    /// clears the buffer and resets the recall cursor.
    /// Test: `submit_records_history`, `history_ring_is_bounded`,
    /// `submit_empty_input_is_noop`.
    pub fn submit_input(&mut self) -> Option<String> {
        let typed = std::mem::take(&mut self.input);
        self.history_cursor = None;
        let trimmed = typed.trim().to_string();
        if trimmed.is_empty() {
            return None;
        }
        self.history.push(trimmed.clone());
        if self.history.len() > INPUT_HISTORY_LIMIT {
            let overflow = self.history.len() - INPUT_HISTORY_LIMIT;
            self.history.drain(0..overflow);
        }
        Some(trimmed)
    }

    /// Recall the previous (older) history entry into the input (↑).
    ///
    /// Why: re-sending or editing a recent message should not require retyping.
    /// What: steps the recall cursor one entry toward the oldest and loads it;
    /// a no-op when history is empty.
    /// Test: `history_recall_walks_entries`.
    pub fn history_prev(&mut self) {
        if self.history.is_empty() {
            return;
        }
        let next = match self.history_cursor {
            None => self.history.len() - 1,
            Some(0) => 0,
            Some(i) => i - 1,
        };
        self.history_cursor = Some(next);
        self.input = self.history[next].clone();
    }

    /// Recall the next (newer) history entry into the input (↓).
    ///
    /// Why: lets the operator step back down after recalling too far with ↑.
    /// What: advances the cursor toward the newest entry; stepping past the
    /// newest clears the cursor and empties the buffer; a no-op when not
    /// currently recalling.
    /// Test: `history_recall_walks_entries`.
    pub fn history_next(&mut self) {
        let Some(i) = self.history_cursor else {
            return;
        };
        if i + 1 >= self.history.len() {
            self.history_cursor = None;
            self.input.clear();
        } else {
            self.history_cursor = Some(i + 1);
            self.input = self.history[i + 1].clone();
        }
    }
}