shep 0.24.2

The shep binary: a process manager that keeps a flock of long-running processes alive on macOS, Linux and Windows, with logs, watch and cron restarts, and webhook alerts
Documentation
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
use super::super::super::app::{App, Body, Control, InputMode, Link, Settings, retrying_sentence};
use super::super::super::pane_bleats::BleatsPane;
use super::super::super::textbox::TextBox;
use super::super::cell;
use super::super::flock::fit;
use super::action_gate::{CONTROL_ENABLED_LABEL, READ_ONLY_LABEL, confirm_prompt, in_flight_text};
use super::key_hint::{
    BLEATS_HINT, CLOSE_DIALOG_HINT, EntryActions, FROZEN_HINT, catalogue_hint, env_armed, hint_for,
    modal_keys, pane_editor, pane_hint, pane_screen, secrets_armed, secrets_hint, secrets_typing,
    sheep_pane_hint,
};
use ratatui::style::Style;
use ratatui::text::{Line, Span};

/// What a text-entry bar names beside its buffer: the one-field editors that
/// apply on enter and cancel on escape. Each bar adds its own gap before it.
pub(in crate::lookout::view) const TEXT_ENTRY_HINT: &str = "enter applies   esc cancels";

/// A text-entry bar: `label`, two columns of gap, the buffer, then `keys`
/// three columns after it. `secret` masks the buffer the way its own row does.
fn entry_line(buffer: &TextBox, secret: bool, label: &str, keys: &str, width: u16) -> String {
    buffer.line(secret, &format!("{label}  "), &format!("   {keys}"), width)
}

/// The banner, when there is one. `None` while the link is live.
///
/// What happened and when the values stopped being current, so an operator
/// reading `online` knows how much to trust it. Once the link is lost,
/// `view::title_band` carries the death sentence above, and this row picks
/// up the `$SHEP_HOME` the title band has no room for.
#[must_use]
pub fn banner_line(app: &App, width: u16) -> Option<Line<'static>> {
    let palette = app.palette();
    match app.link() {
        Link::Live => None,
        Link::Retrying { attempt } => Some(Line::from(Span::styled(
            retrying_sentence(*attempt),
            palette.attention(),
        ))),
        // Through `fit`, unlike the retrying sentence above, which is short
        // enough that no terminal cuts it. `Buffer::set_line` cuts in
        // silence: a mid-word cut beside lines that mark their own
        // truncation reads as a rendering fault.
        Link::Lost { .. } => Some(Line::from(Span::styled(
            fit(
                &format!(
                    " shep lookout   {}  ·  the dashboard stays up so you can read what it had  ·  it will not exit on its own",
                    app.place()
                ),
                width,
            ),
            palette.muted(),
        ))),
    }
}

/// The bottom line: eight slots, highest priority first.
///
/// The settings screen's armed or in-flight edit, a dashboard confirm, its
/// free-text editor, the filter box. A notice, an in-flight action, the
/// applied filter line, then the key hint. Control state always renders on
/// the right, and the row is never cut.
#[must_use]
pub fn status_line(app: &App, width: u16) -> Line<'static> {
    let palette = app.palette();
    // Always rendered, in both states. An operator who cannot tell whether
    // the dashboard can act finds out the wrong way. The bleats pane borrows
    // this slot while following the tail: it has no action keys of its own,
    // and pinning is what its reader needs.
    let right = if matches!(app.link(), Link::Lost { .. }) {
        // Ahead of both: whether this dashboard is reading a live shepherd
        // outranks whether a pane is pinned to the newest line, and outranks
        // a control state the lost link already settles.
        "\u{2588} frozen"
    } else if app.bleats_pane().is_some_and(BleatsPane::following) {
        "\u{2588} following"
    } else {
        match app.control() {
            Control::ReadOnly => READ_ONLY_LABEL,
            Control::Allowed => CONTROL_ENABLED_LABEL,
        }
    };
    let right_len = u16::try_from(right.chars().count()).unwrap_or(0);
    // `+ 1` reserves one column of gap so a truncated left side's `…` never
    // butts against the label. The gap rides inside the right span, styled
    // like the label, keeping the line two spans rather than three.
    let left_width = width.saturating_sub(right_len).saturating_sub(1);
    let (left, left_style) = if let Some(modal) = app.modal() {
        // First: a modal takes every key ahead of whatever is under it, so
        // any other hint would name keys that do nothing.
        (
            modal_keys(modal, app.modal_scrolls()).to_string(),
            palette.attention(),
        )
    } else if let Some(prompt) = app.settings().and_then(Settings::pending) {
        // The settings screen's armed scalar or dog edit, or its in-flight
        // sentence once sent. Opening clears `self.action`. `on_key` routes
        // to the settings keymap, so no dashboard confirm can arm while it
        // stays open.
        let text = if prompt.sent {
            format!("{}  sent, waiting for the shepherd", prompt.text)
        } else {
            format!("{}  enter confirms, any other key cancels", prompt.text)
        };
        (text, palette.attention())
    } else if let Some(buffer) = app.bleats_pane().and_then(BleatsPane::match_editing) {
        // Ahead of the filter branch for the reason the comment below gives:
        // the bleats pane's match box shares `InputMode::Text` with the
        // dashboard's name filter, and falling through would label the
        // dashboard's own untouched query as this pane's match.
        (
            entry_line(buffer, false, "match", TEXT_ENTRY_HINT, left_width),
            palette.attention(),
        )
    } else if let Some(buffer) = app
        .sheep_pane()
        .and_then(|pane| pane.feed().match_editing())
    {
        // The same box, embedded: the sheep pane's feed shares
        // `InputMode::Text` with the dashboard's name filter. A fall-through
        // here would label the dashboard's untouched query as this feed's
        // match.
        (
            entry_line(buffer, false, "match", TEXT_ENTRY_HINT, left_width),
            palette.attention(),
        )
    } else if let Some(editor) = app.config_pane().and_then(pane_editor) {
        // The pane's own free-text editor, and the key/value editor the env
        // rows open, ahead of the filter branch: all three share
        // `InputMode::Text`, and a bar that fell through would label this the
        // dashboard's own query. The keys come from the slot, not from here.
        (
            entry_line(
                editor.buffer,
                editor.secret,
                &editor.label,
                editor.keys,
                left_width,
            ),
            palette.attention(),
        )
    } else if let Some(action) = app.action().filter(|a| !a.sent) {
        // A question awaiting an answer outranks everything, including the
        // filter box: `/` cancels a confirm before it opens the box.
        (confirm_prompt(&action), palette.attention())
    } else if let Some((settings, label, buffer)) = app.settings().and_then(|settings| {
        settings
            .typing()
            .map(|(label, buffer)| (settings, label, buffer))
    }) {
        // Checked ahead of the filter-box branch below: both share
        // `InputMode::Text`, but this types into a `shep.toml` key, not
        // `App::filter`.
        (
            entry_line(
                buffer,
                settings.typing_is_secret(),
                &format!("editing {label}"),
                TEXT_ENTRY_HINT,
                left_width,
            ),
            palette.attention(),
        )
    } else if let Some(key) = app.config_pane().and_then(env_armed) {
        // The config pane's one armed thing, ranked with the secrets pane's
        // own below for the same reason: it is a question awaiting an answer,
        // and it must outrank `pane_hint`, which still reads `e edit` while an
        // arm is live.
        (
            format!("delete {key}? enter confirms, any other key cancels"),
            palette.attention(),
        )
    } else if let Some(editor) = secrets_typing(app) {
        // Ahead of the filter-box branch below, for the reason the config
        // pane's own free-text branch above gives: this shares
        // `InputMode::Text` with `App::filter` too.
        (
            entry_line(
                editor.buffer,
                editor.secret,
                &editor.label,
                editor.keys,
                left_width,
            ),
            palette.attention(),
        )
    } else if let Some(key) = secrets_armed(app) {
        // Ranked with the dashboard's own confirm above, for the same
        // reason: an armed delete is a question awaiting an answer, and it
        // must outrank `key_hint::secrets_hint`, which still reads `enter sets a
        // value` while an arm is live.
        (
            format!("delete {key}? enter confirms, any other key cancels"),
            palette.attention(),
        )
    } else if app.mode() == InputMode::Text {
        // Above the notice: bus events arrive with no keypress and
        // `on_text_key` never clears them, so ranking the notice higher
        // would erase a half-typed query. The cursor is a character, not
        // a style: the ANSI gallery renders foregrounds only.
        (
            entry_line(
                app.filter_box(),
                false,
                "filter",
                &format!("{TEXT_ENTRY_HINT}   ctrl-c quits"),
                left_width,
            ),
            palette.attention(),
        )
    } else if let Some(notice) = app.notice() {
        (
            with_long_actions(app, notice.to_string(), left_width, false),
            if notice.is_grave() {
                palette.refusal()
            } else {
                palette.attention()
            },
        )
    } else if let Some(action) = app.action() {
        // Below the notice: `arm`'s "one action is already in flight"
        // refusal is itself a notice, so ranking this above notices would
        // hide it. A keypress cannot wipe this line; the reducer clears
        // the notice, not this ordering.
        let text = in_flight_text(&action);
        // `attention`, the same butter the non-grave notice uses. No modal,
        // no box, no `ratatui::widgets::Clear`. One rule under the header
        // beats a full border for a pane read at 3am. This module draws no
        // overlay.
        (text, palette.attention())
    } else {
        body_hint(app, left_width)
    };
    // `patch`, not a fresh `Style`. `ground` sets only the background, so
    // patching it keeps each span's own foreground. `fit` pads `left` to
    // `left_width`, so the ground reaches every column the label does not.
    let ground = palette.ground();
    Line::from(vec![
        Span::styled(fit(&left, left_width), left_style.patch(ground)),
        Span::styled(format!(" {right}"), palette.muted().patch(ground)),
    ])
}

/// The key hint for whatever body is up, and the style it is drawn in, with
/// the dog job ahead of it and, on the dashboard's own keys only, the flock
/// hint after it.
///
/// Reached only when [`status_line`] has no question, editor, notice or
/// in-flight line to show: a hint naming keys the screen is not taking is the
/// asterisk this file's standing rule forbids.
#[must_use]
fn body_hint(app: &App, width: u16) -> (String, Style) {
    let (keys, style, is_dashboard) = screen_hint(app);
    let text = with_dog_job(app, &keys, width);
    let text = with_long_actions(app, text, width, true);
    if is_dashboard {
        (with_flock_hint(app, text, width), style)
    } else {
        (text, style)
    }
}

/// The keys of whatever screen is up, their style, and whether they are the
/// dashboard's own idle keys: the one bar the flock hint may ride.
fn screen_hint(app: &App) -> (String, Style, bool) {
    let palette = app.palette();
    if app.close_dialog().is_some() {
        // Ahead of the config pane's own hint below: the dialog owns the
        // keyboard while it is up. A bar naming `space cycle` under that
        // screen would be the asterisk this rule forbids.
        // Muted: the dialog's own rows name what each key does.
        return (CLOSE_DIALOG_HINT.to_string(), palette.muted(), false);
    }
    if let Some(pane) = app.config_pane() {
        // The pane owns the keyboard, so neither dashboard hint is true
        // while it is up. Naming `x stop` where `x` does nothing is the
        // asterisk this rule forbids. Butter: this is a key hint, same as
        // the dashboard's own below.
        return (
            pane_hint(app.control(), pane_screen(pane)).to_string(),
            palette.attention(),
            false,
        );
    }
    if app.bleats_pane().is_some() {
        // Checked below the match box's branch, which owns this slot while
        // open. The design's status-bar line
        // (docs/lookout/design-files/README.md:274) names no key for `m`, so
        // it is appended rather than inserted.
        return (BLEATS_HINT.to_string(), palette.attention(), false);
    }
    if matches!(app.body(), Body::Secrets(_)) {
        // The pane owns the keyboard here too, same reasoning as the config
        // pane's own branch above: `x stop`/`R restart`/`L reload`/`F folds`
        // belong to the dashboard underneath and do nothing on this screen.
        return (secrets_hint(app.control()), palette.attention(), false);
    }
    if let Body::Catalogue(pane) = app.body() {
        // The open row is named by `adopt_as`, which is also what each
        // action addresses it by.
        let actions = pane.entry.as_ref().map(|name| {
            let row = app
                .catalogue_rows()
                .iter()
                .find(|row| row.dog.adopt_as == *name);
            EntryActions {
                install: row.is_some_and(|row| app.can_install(row)),
                upgrade: row.is_some_and(|row| app.can_upgrade(row)),
                delete: row.is_some_and(|row| app.can_delete(row)),
            }
        });
        return (catalogue_hint(actions), palette.attention(), false);
    }
    if app.sheep_pane().is_some() {
        // Checked below the bleats pane's own branch, the same as the
        // config pane's above it: the full-screen panes cannot be open at
        // once, so their order here is documentation, not correctness.
        return (
            sheep_pane_hint(app.control()).to_string(),
            palette.attention(),
            false,
        );
    }
    if app.settings().is_none() && !app.filter().is_empty() {
        // Gated on the screen being closed: the filter survives the swap
        // into settings (`App::on_settings_key` never touches it), but `/`
        // and `esc` mean something else entirely while the screen owns the
        // keyboard, so this line would be false the moment it stayed up.
        return (
            format!("filter \"{}\"   / edit   esc clear", app.filter()),
            palette.muted(),
            false,
        );
    }
    if matches!(app.link(), Link::Lost { .. }) {
        // Only this branch, not the pane hints above. A pane opened before
        // the freeze keeps naming its own keys. This line is the flock
        // table's. Every action key `key_hint::hint_for` would name is
        // refused once the link is gone.
        return (FROZEN_HINT.to_string(), palette.attention(), false);
    }
    // Butter: the keys, same rule as the pane's own hint above.
    (
        hint_for(app.control(), app.settings().is_some(), app.grouping()),
        palette.attention(),
        app.settings().is_none(),
    )
}

/// Puts the running dog job's item ahead of `hint`, or drops it where both
/// would not fit.
///
/// The job goes first: the keys are what every screen needs to keep working
/// while an install runs, and the item is the one thing on the bar that says
/// what this machine is doing on its own.
fn with_dog_job(app: &App, hint: &str, width: u16) -> String {
    let Some(job) = app.dog_job_line() else {
        return hint.to_owned();
    };
    let joined = format!("{job}   {hint}");
    let Ok(needed) = u16::try_from(joined.chars().count()) else {
        return hint.to_owned();
    };
    if needed <= width {
        joined
    } else {
        hint.to_owned()
    }
}

/// Puts the long-action items ahead of `text`.
///
/// The running item is dropped where both would not fit, for the dog job's
/// reason: the keys are what the screen needs. With `result_wins`, a result
/// is kept and stands in for `text` while it shows, since a failure's error
/// is read once. A notice is not given up that way.
fn with_long_actions(app: &App, text: String, width: u16, result_wins: bool) -> String {
    let fits = |line: &str| u16::try_from(line.chars().count()).is_ok_and(|needed| needed <= width);
    let result = app.long_action_result();
    let running = app.long_actions_running();
    let items: Vec<&str> = result.into_iter().chain(running.as_deref()).collect();
    if items.is_empty() {
        return text;
    }
    let joined = format!("{}   {text}", items.join("   "));
    if fits(&joined) {
        joined
    } else if let Some(result) = result.filter(|_| result_wins) {
        result.to_owned()
    } else {
        text
    }
}

/// Puts the flock hint after `text`, or drops it where both would not fit.
///
/// The keys are never cut to make room: they are what the bar is for, and the
/// hint is the one thing on it that is only advice.
fn with_flock_hint(app: &App, text: String, width: u16) -> String {
    let Some(hint) = app.hint_line() else {
        return text;
    };
    let joined = format!("{text}   {hint}");
    match u16::try_from(joined.chars().count()) {
        Ok(needed) if needed <= width => joined,
        _ => text,
    }
}

/// A run of `─` across the pane, under the header.
///
/// One rule, not a box. `output::table`'s own doc argues that a table a
/// user can `awk` over beats one that looks nice; the same instinct applies
/// to a pane an operator reads at 3am, and a full border costs two columns
/// and two rows of the thing they are trying to read.
#[must_use]
pub fn rule_line(style: Style, width: u16) -> Line<'static> {
    Line::from(Span::styled(cell::rule(usize::from(width)), style))
}

#[cfg(test)]
mod tests;