Skip to main content

databricks_tui/
ui.rs

1use crate::app::{App, Panel, ThemeMode};
2use crate::shape::{Shape, Status};
3use ratatui::{
4    layout::{Alignment, Constraint, Direction, Layout, Rect},
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{
8        Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph,
9        Row, Table, Wrap,
10    },
11    Frame,
12};
13use std::time::Duration;
14
15struct Palette {
16    text: Color,
17    dim: Color,
18    border: Color,
19    warn: Color,
20    ok: Color,
21    err: Color,
22    key: Color,
23    brand: Color,
24    clusters: Color,
25    jobs: Color,
26    pipelines: Color,
27    warehouses: Color,
28    catalog: Color,
29    /// Header wordmark gradient endpoints.
30    grad_from: (u8, u8, u8),
31    grad_to: (u8, u8, u8),
32}
33
34const fn rgb(hex: u32) -> Color {
35    Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
36}
37
38const fn rgb3(hex: u32) -> (u8, u8, u8) {
39    ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
40}
41
42fn palette(mode: ThemeMode) -> Palette {
43    match mode {
44        // Dark theme sticks to ANSI colors so it follows the terminal's own scheme.
45        ThemeMode::Dark => Palette {
46            text: Color::White,
47            dim: Color::DarkGray,
48            border: Color::DarkGray,
49            warn: Color::Yellow,
50            ok: Color::Green,
51            err: Color::Red,
52            key: Color::Cyan,
53            brand: Color::Red,
54            clusters: Color::Cyan,
55            jobs: Color::Magenta,
56            pipelines: Color::Green,
57            warehouses: Color::Blue,
58            catalog: rgb(0xFF8C42),
59            grad_from: rgb3(0xFF3621),
60            grad_to: rgb3(0xFFA046),
61        },
62        // Light theme uses explicit darker shades that stay readable on a white background.
63        ThemeMode::Light => Palette {
64            text: Color::Black,
65            dim: rgb(0x6B7280),
66            border: rgb(0x9CA3AF),
67            warn: rgb(0xB45309),
68            ok: rgb(0x15803D),
69            err: rgb(0xB91C1C),
70            key: rgb(0x0891B2),
71            brand: rgb(0xDC2626),
72            clusters: rgb(0x0891B2),
73            jobs: rgb(0xA21CAF),
74            pipelines: rgb(0x15803D),
75            warehouses: rgb(0x1D4ED8),
76            catalog: rgb(0xC2410C),
77            grad_from: rgb3(0xB91C1C),
78            grad_to: rgb3(0xC2410C),
79        },
80        ThemeMode::CatppuccinMocha => Palette {
81            text: rgb(0xCDD6F4),
82            dim: rgb(0x6C7086),
83            border: rgb(0x585B70),
84            warn: rgb(0xF9E2AF),
85            ok: rgb(0xA6E3A1),
86            err: rgb(0xF38BA8),
87            key: rgb(0x89DCEB),
88            brand: rgb(0xF38BA8),
89            clusters: rgb(0x89DCEB),
90            jobs: rgb(0xCBA6F7),
91            pipelines: rgb(0xA6E3A1),
92            warehouses: rgb(0x89B4FA),
93            catalog: rgb(0xFAB387),
94            grad_from: rgb3(0xF38BA8),
95            grad_to: rgb3(0xFAB387),
96        },
97        ThemeMode::CatppuccinLatte => Palette {
98            text: rgb(0x4C4F69),
99            dim: rgb(0x8C8FA1),
100            border: rgb(0xACB0BE),
101            warn: rgb(0xDF8E1D),
102            ok: rgb(0x40A02B),
103            err: rgb(0xD20F39),
104            key: rgb(0x04A5E5),
105            brand: rgb(0xD20F39),
106            clusters: rgb(0x04A5E5),
107            jobs: rgb(0x8839EF),
108            pipelines: rgb(0x40A02B),
109            warehouses: rgb(0x1E66F5),
110            catalog: rgb(0xFE640B),
111            grad_from: rgb3(0xD20F39),
112            grad_to: rgb3(0xFE640B),
113        },
114        ThemeMode::GruvboxDark => Palette {
115            text: rgb(0xEBDBB2),
116            dim: rgb(0x928374),
117            border: rgb(0x665C54),
118            warn: rgb(0xFABD2F),
119            ok: rgb(0xB8BB26),
120            err: rgb(0xFB4934),
121            key: rgb(0x8EC07C),
122            brand: rgb(0xFB4934),
123            clusters: rgb(0x8EC07C),
124            jobs: rgb(0xD3869B),
125            pipelines: rgb(0xB8BB26),
126            warehouses: rgb(0x83A598),
127            catalog: rgb(0xFE8019),
128            grad_from: rgb3(0xFB4934),
129            grad_to: rgb3(0xFE8019),
130        },
131        ThemeMode::Dracula => Palette {
132            text: rgb(0xF8F8F2),
133            dim: rgb(0x6272A4),
134            border: rgb(0x44475A),
135            warn: rgb(0xF1FA8C),
136            ok: rgb(0x50FA7B),
137            err: rgb(0xFF5555),
138            key: rgb(0x8BE9FD),
139            brand: rgb(0xFF5555),
140            clusters: rgb(0x8BE9FD),
141            jobs: rgb(0xFF79C6),
142            pipelines: rgb(0x50FA7B),
143            warehouses: rgb(0xBD93F9),
144            catalog: rgb(0xFFB86C),
145            grad_from: rgb3(0xFF5555),
146            grad_to: rgb3(0xFFB86C),
147        },
148        ThemeMode::Nord => Palette {
149            text: rgb(0xD8DEE9),
150            dim: rgb(0x4C566A),
151            border: rgb(0x434C5E),
152            warn: rgb(0xEBCB8B),
153            ok: rgb(0xA3BE8C),
154            err: rgb(0xBF616A),
155            key: rgb(0x88C0D0),
156            brand: rgb(0xBF616A),
157            clusters: rgb(0x88C0D0),
158            jobs: rgb(0xB48EAD),
159            pipelines: rgb(0xA3BE8C),
160            warehouses: rgb(0x81A1C1),
161            catalog: rgb(0xD08770),
162            grad_from: rgb3(0xBF616A),
163            grad_to: rgb3(0xD08770),
164        },
165        ThemeMode::TokyoNight => Palette {
166            text: rgb(0xC0CAF5),
167            dim: rgb(0x565F89),
168            border: rgb(0x3B4261),
169            warn: rgb(0xE0AF68),
170            ok: rgb(0x9ECE6A),
171            err: rgb(0xF7768E),
172            key: rgb(0x7DCFFF),
173            brand: rgb(0xF7768E),
174            clusters: rgb(0x7DCFFF),
175            jobs: rgb(0xBB9AF7),
176            pipelines: rgb(0x9ECE6A),
177            warehouses: rgb(0x7AA2F7),
178            catalog: rgb(0xFF9E64),
179            grad_from: rgb3(0xF7768E),
180            grad_to: rgb3(0xFF9E64),
181        },
182    }
183}
184
185fn accent(panel: Panel, p: &Palette) -> Color {
186    match panel {
187        Panel::Clusters => p.clusters,
188        Panel::Jobs => p.jobs,
189        Panel::Pipelines => p.pipelines,
190        Panel::Warehouses => p.warehouses,
191        Panel::Dashboards => p.key,
192        Panel::Catalog => p.catalog,
193    }
194}
195
196pub fn draw(f: &mut Frame, app: &App) {
197    let p = palette(app.theme);
198
199    if app.splash_active() {
200        draw_splash(f, f.area(), app, &p);
201        return;
202    }
203
204    let root = Layout::default()
205        .direction(Direction::Vertical)
206        .constraints([
207            Constraint::Length(3),
208            Constraint::Min(0),
209            Constraint::Length(1),
210        ])
211        .split(f.area());
212
213    draw_header(f, root[0], app, &p);
214    draw_footer(f, root[2], app, &p);
215
216    if app.sql.is_some() {
217        draw_sql(f, root[1], app, &p);
218        // Running a query may pop the warehouse picker over the console.
219        if app.wh_picker.is_some() {
220            draw_wh_picker(f, root[1], app, &p);
221        }
222        return;
223    }
224
225    if app.cost.is_some() {
226        draw_cost(f, root[1], app, &p);
227        return;
228    }
229
230    if app.preview.is_some() {
231        draw_preview(f, root[1], app, &p);
232        return;
233    }
234
235    if app.run_view.is_some() {
236        draw_run(f, root[1], app, &p);
237        return;
238    }
239
240    if app.detail.is_some() {
241        draw_detail(f, root[1], app, &p);
242        return;
243    }
244
245    if app.zoomed {
246        let idx = Panel::ALL
247            .iter()
248            .position(|pn| pn == &app.focus)
249            .unwrap_or(0);
250        draw_panel(
251            f,
252            root[1],
253            app.focus,
254            app.shapes[idx].as_ref(),
255            true,
256            Some(app.selection(idx)),
257            false,
258            &app.uc_path.join("."),
259            app.spinner(),
260            &app.filters[idx],
261            app.filter_entry,
262            &p,
263        );
264        // Overlays must still render on top of the zoomed pane.
265        if app.picker.is_some() {
266            draw_picker(f, root[1], app, &p);
267        }
268        if app.wh_picker.is_some() {
269            draw_wh_picker(f, root[1], app, &p);
270        }
271        if app.problems.is_some() {
272            draw_problems(f, root[1], app, &p);
273        }
274        if app.jump.is_some() {
275            draw_jump(f, root[1], app, &p);
276        }
277        if app.pane_cfg.is_some() {
278            draw_pane_cfg(f, root[1], app, &p);
279        }
280        if app.help {
281            draw_help(f, root[1], app, &p);
282        }
283        return;
284    }
285
286    // The grid adapts to however many panes are visible, in the user's
287    // order: left column gets the first half (rounded up), right the rest.
288    let visible = app.visible_panes();
289    if visible.is_empty() {
290        let par = Paragraph::new("all panes hidden — press H to bring them back")
291            .style(Style::default().fg(p.dim))
292            .alignment(Alignment::Center);
293        f.render_widget(par, root[1]);
294    } else {
295        let rows_of = |n: usize| -> Vec<Constraint> {
296            (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
297        };
298        let left_count = visible.len().div_ceil(2);
299        let right_count = visible.len() - left_count;
300        let mut areas: Vec<Rect> = Vec::new();
301        if right_count == 0 {
302            areas.extend(
303                Layout::default()
304                    .direction(Direction::Vertical)
305                    .constraints(rows_of(left_count))
306                    .split(root[1])
307                    .iter(),
308            );
309        } else {
310            let body = Layout::default()
311                .direction(Direction::Horizontal)
312                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
313                .split(root[1]);
314            areas.extend(
315                Layout::default()
316                    .direction(Direction::Vertical)
317                    .constraints(rows_of(left_count))
318                    .split(body[0])
319                    .iter(),
320            );
321            areas.extend(
322                Layout::default()
323                    .direction(Direction::Vertical)
324                    .constraints(rows_of(right_count))
325                    .split(body[1])
326                    .iter(),
327            );
328        }
329
330        for (slot, &i) in visible.iter().enumerate() {
331            let panel = Panel::ALL[i];
332            let focused = app.focus == panel;
333            let shape = app.shapes[i].as_ref();
334            let selected = focused.then(|| app.selection(i));
335            let fresh = app.updated_at[i]
336                .map(|t| t.elapsed() < Duration::from_millis(1200))
337                .unwrap_or(false);
338            draw_panel(
339                f,
340                areas[slot],
341                panel,
342                shape,
343                focused,
344                selected,
345                fresh,
346                &app.uc_path.join("."),
347                app.spinner(),
348                &app.filters[i],
349                app.filter_entry && focused,
350                &p,
351            );
352        }
353    }
354
355    if app.picker.is_some() {
356        draw_picker(f, root[1], app, &p);
357    }
358    if app.wh_picker.is_some() {
359        draw_wh_picker(f, root[1], app, &p);
360    }
361    if app.problems.is_some() {
362        draw_problems(f, root[1], app, &p);
363    }
364    if app.jump.is_some() {
365        draw_jump(f, root[1], app, &p);
366    }
367    if app.pane_cfg.is_some() {
368        draw_pane_cfg(f, root[1], app, &p);
369    }
370    if app.help {
371        draw_help(f, root[1], app, &p);
372    }
373}
374
375fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
376    let Some(selected) = app.pane_cfg else {
377        return;
378    };
379    let width = 52.min(area.width.saturating_sub(4));
380    let height = 11.min(area.height);
381    let popup = Rect {
382        x: area.x + (area.width.saturating_sub(width)) / 2,
383        y: area.y + (area.height.saturating_sub(height)) / 2,
384        width,
385        height,
386    };
387    f.render_widget(Clear, popup);
388    let block = Block::default()
389        .title(Line::from(vec![
390            Span::styled(" ▦ ", Style::default().fg(p.key)),
391            Span::styled(
392                "Panes ",
393                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
394            ),
395        ]))
396        .borders(Borders::ALL)
397        .border_type(BorderType::Thick)
398        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
399        .padding(Padding::new(1, 1, 1, 0));
400    let items: Vec<ListItem> = app
401        .pane_order
402        .iter()
403        .map(|&i| {
404            let panel = Panel::ALL[i];
405            let (mark, style) = if app.hidden[i] {
406                ("○ ", Style::default().fg(p.dim))
407            } else {
408                ("● ", Style::default().fg(p.ok))
409            };
410            ListItem::new(Line::from(vec![
411                Span::styled(mark, style),
412                Span::styled(
413                    format!("{} ", panel.icon()),
414                    Style::default().fg(accent(panel, p)),
415                ),
416                Span::styled(
417                    panel.title(),
418                    if app.hidden[i] {
419                        Style::default().fg(p.dim)
420                    } else {
421                        Style::default().fg(p.text)
422                    },
423                ),
424            ]))
425        })
426        .collect();
427    let list = List::new(items)
428        .block(block)
429        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
430    let mut state = ListState::default().with_selected(Some(selected));
431    f.render_stateful_widget(list, popup, &mut state);
432}
433
434fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
435    let width = 74.min(area.width.saturating_sub(4));
436    let height = area.height.saturating_sub(2);
437    let popup = Rect {
438        x: area.x + (area.width.saturating_sub(width)) / 2,
439        y: area.y + 1,
440        width,
441        height,
442    };
443    f.render_widget(Clear, popup);
444    let block = Block::default()
445        .title(Line::from(vec![
446            Span::styled(" ? ", Style::default().fg(p.key)),
447            Span::styled(
448                "Keys ",
449                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
450            ),
451        ]))
452        .borders(Borders::ALL)
453        .border_type(BorderType::Thick)
454        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
455        .padding(Padding::new(1, 1, 1, 0));
456
457    let sections: &[(&str, &[(&str, &str)])] = &[
458        (
459            "Global",
460            &[
461                ("tab / l, shift+tab / h", "focus next / previous pane"),
462                ("j / k", "select item"),
463                ("enter", "open details / drill down"),
464                ("/", "filter the focused pane (esc clears)"),
465                ("ctrl+p", "command palette: fuzzy-jump anywhere"),
466                ("!", "problems: everything failing, enter jumps"),
467                (":", "SQL console (prefilled on a catalog table)"),
468                ("$", "cost view: DBUs, dollars, top spenders"),
469                ("H", "arrange panes: hide and reorder"),
470                ("z", "zoom the focused pane"),
471                ("w", "switch workspace profile"),
472                ("t", "cycle color theme"),
473                ("r", "refresh now"),
474                ("?", "this help"),
475                ("q / ctrl+c", "quit"),
476            ],
477        ),
478        (
479            "Focused pane",
480            &[
481                ("s", "start / stop / run the selected item"),
482                ("g", "access: grants and permissions"),
483                ("o", "open in the workspace web UI"),
484            ],
485        ),
486        (
487            "Unity Catalog pane",
488            &[
489                ("enter / backspace", "drill down / up (into volumes too)"),
490                ("p / P", "preview table rows (P picks the warehouse)"),
491                ("L", "lineage: upstream and downstream tables"),
492                (":", "query the selected table"),
493                ("enter on a file", "peek at its first 200 lines"),
494            ],
495        ),
496        (
497            "Details & runs",
498            &[
499                ("enter", "job/pipeline detail → latest run/update"),
500                ("h / l", "older / newer run"),
501                ("s", "cancel the shown run"),
502                ("j / k, J", "scroll · toggle raw JSON"),
503                ("e", "in previews: export rows to CSV"),
504            ],
505        ),
506        (
507            "SQL console",
508            &[
509                ("enter", "run the statement"),
510                ("↑ / ↓, ctrl+r", "history · incremental search"),
511                ("ctrl+x", "compose in $EDITOR"),
512                ("ctrl+s", "export results to CSV"),
513                ("pgup / pgdn", "scroll results"),
514                ("esc", "cancel a running query, else close"),
515            ],
516        ),
517    ];
518    let mut lines: Vec<Line> = Vec::new();
519    for (title, keys) in sections {
520        lines.push(Line::from(Span::styled(
521            *title,
522            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
523        )));
524        for (k, desc) in *keys {
525            lines.push(Line::from(vec![
526                Span::styled(format!("  {k:<24}"), Style::default().fg(p.warn)),
527                Span::styled(*desc, Style::default().fg(p.text)),
528            ]));
529        }
530        lines.push(Line::default());
531    }
532    let par = Paragraph::new(lines)
533        .scroll((app.help_scroll, 0))
534        .block(block);
535    f.render_widget(par, popup);
536}
537
538fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
539    let Some(jump) = &app.jump else {
540        return;
541    };
542    let matches = app.jump_matches();
543    let width = 70.min(area.width.saturating_sub(4));
544    let height = (matches.len() as u16 + 5).min(area.height);
545    let popup = Rect {
546        x: area.x + (area.width.saturating_sub(width)) / 2,
547        y: area.y + 2.min(area.height.saturating_sub(height)),
548        width,
549        height,
550    };
551    f.render_widget(Clear, popup);
552    let block = Block::default()
553        .title(Line::from(vec![
554            Span::styled(" ⌕ ", Style::default().fg(p.key)),
555            Span::styled(
556                "Jump to ",
557                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
558            ),
559        ]))
560        .borders(Borders::ALL)
561        .border_type(BorderType::Thick)
562        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
563        .padding(Padding::horizontal(1));
564    let inner = block.inner(popup);
565    f.render_widget(block, popup);
566
567    let parts = Layout::default()
568        .direction(Direction::Vertical)
569        .constraints([Constraint::Length(2), Constraint::Min(0)])
570        .split(inner);
571    f.render_widget(
572        Paragraph::new(Line::from(vec![
573            Span::styled("❯ ", Style::default().fg(p.key)),
574            Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
575            Span::styled("▏", Style::default().fg(p.warn)),
576        ])),
577        parts[0],
578    );
579
580    if matches.is_empty() {
581        f.render_widget(
582            Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
583            parts[1],
584        );
585        return;
586    }
587    let items: Vec<ListItem> = matches
588        .iter()
589        .map(|(panel_idx, name, label)| {
590            let panel = Panel::ALL[*panel_idx];
591            ListItem::new(Line::from(vec![
592                Span::styled(
593                    format!("{} ", panel.icon()),
594                    Style::default().fg(accent(panel, p)),
595                ),
596                Span::styled(format!("{name}  "), Style::default().fg(p.text)),
597                Span::styled(label.clone(), Style::default().fg(p.dim)),
598            ]))
599        })
600        .collect();
601    let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
602    let mut state = ListState::default().with_selected(Some(jump.index));
603    f.render_stateful_widget(list, parts[1], &mut state);
604}
605
606fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
607    let Some(pr) = &app.problems else {
608        return;
609    };
610    let truncate = |s: &str, max: usize| -> String {
611        if s.chars().count() <= max {
612            s.to_string()
613        } else {
614            let cut: String = s.chars().take(max.saturating_sub(1)).collect();
615            format!("{cut}…")
616        }
617    };
618    // Names get a bounded column; notes (which can be whole CLI error
619    // lines) take whatever room is left and are truncated to fit.
620    let name_w = pr
621        .items
622        .iter()
623        .map(|i| i.name.chars().count())
624        .max()
625        .unwrap_or(10)
626        .clamp(10, 40);
627    let note_w = pr
628        .items
629        .iter()
630        .map(|i| i.note.chars().count())
631        .max()
632        .unwrap_or(0);
633    // dot(2) + name + gap(2) + icon+title(20) + note + padding/borders(8)
634    let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
635    let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
636    // Room left for the note on each row inside borders and padding.
637    let note_space = (width as usize).saturating_sub(name_w + 30);
638    let popup = Rect {
639        x: area.x + (area.width.saturating_sub(width)) / 2,
640        y: area.y + (area.height.saturating_sub(height)) / 2,
641        width,
642        height,
643    };
644    f.render_widget(Clear, popup);
645    let block = Block::default()
646        .title(Line::from(vec![
647            Span::styled(" ✗ ", Style::default().fg(p.err)),
648            Span::styled(
649                format!("Problems · {} ", pr.items.len()),
650                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
651            ),
652        ]))
653        .borders(Borders::ALL)
654        .border_type(BorderType::Rounded)
655        .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
656        .padding(Padding::new(1, 1, 1, 1));
657
658    if pr.items.is_empty() {
659        let par = Paragraph::new("✓ all clear — nothing failing right now")
660            .style(Style::default().fg(p.ok))
661            .block(block);
662        f.render_widget(par, popup);
663        return;
664    }
665
666    let items: Vec<ListItem> = pr
667        .items
668        .iter()
669        .map(|problem| {
670            let panel = Panel::ALL[problem.panel];
671            ListItem::new(Line::from(vec![
672                Span::styled("✗ ", Style::default().fg(p.err)),
673                Span::styled(
674                    format!(
675                        "{:<width$}",
676                        truncate(&problem.name, name_w),
677                        width = name_w + 2
678                    ),
679                    Style::default().fg(p.text),
680                ),
681                Span::styled(
682                    format!("{} {:<18}", panel.icon(), panel.title()),
683                    Style::default().fg(accent(panel, p)),
684                ),
685                Span::styled(
686                    truncate(&problem.note, note_space),
687                    Style::default().fg(p.dim),
688                ),
689            ]))
690        })
691        .collect();
692    let list = List::new(items)
693        .block(block)
694        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
695    let mut state = ListState::default().with_selected(Some(pr.index));
696    f.render_stateful_widget(list, popup, &mut state);
697}
698
699fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
700    let Some(picker) = &app.wh_picker else {
701        return;
702    };
703    let warehouses = app.warehouses();
704    let name_w = warehouses
705        .iter()
706        .map(|(n, _, _)| n.chars().count())
707        .max()
708        .unwrap_or(12)
709        .max(12);
710    // marker(2) + dot(2) + name + gap(2) + state(7) + gap(2) + id(16) + padding/borders(6)
711    let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
712    let height = (warehouses.len() as u16 + 4).min(area.height);
713    let popup = Rect {
714        x: area.x + (area.width.saturating_sub(width)) / 2,
715        y: area.y + (area.height.saturating_sub(height)) / 2,
716        width,
717        height,
718    };
719    f.render_widget(Clear, popup);
720    let block = Block::default()
721        .title(Line::from(vec![
722            Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
723            Span::styled(
724                "Run query on ",
725                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
726            ),
727        ]))
728        .borders(Borders::ALL)
729        .border_type(BorderType::Rounded)
730        .border_style(
731            Style::default()
732                .fg(p.warehouses)
733                .add_modifier(Modifier::BOLD),
734        )
735        .padding(Padding::new(1, 1, 1, 1));
736    let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
737    let items: Vec<ListItem> = warehouses
738        .iter()
739        .map(|(name, id, running)| {
740            let dot = if *running { p.ok } else { p.dim };
741            let marker = if current_id == Some(id.as_str()) {
742                "» "
743            } else {
744                "  "
745            };
746            let state = if *running { "running" } else { "idle   " };
747            ListItem::new(Line::from(vec![
748                Span::styled(marker, Style::default().fg(p.key)),
749                Span::styled("● ", Style::default().fg(dot)),
750                Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
751                Span::styled(
752                    format!("  {state}"),
753                    Style::default().fg(if *running { p.ok } else { p.dim }),
754                ),
755                Span::styled(format!("  {id}"), Style::default().fg(p.dim)),
756            ]))
757        })
758        .collect();
759    let list = List::new(items)
760        .block(block)
761        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
762    let mut state = ListState::default().with_selected(Some(picker.index));
763    f.render_stateful_widget(list, popup, &mut state);
764}
765
766const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
767const WORDMARK_BOT: &str = "█▄▀ █▀█  █  █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
768
769fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
770    let lines_total = 9u16;
771    let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
772    let frame = app.spinner_frame();
773
774    // Brick-mark pyramid in Databricks red.
775    let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
776    for (i, row) in bricks.iter().enumerate() {
777        let line = Line::from(Span::styled(
778            *row,
779            Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
780        ));
781        let rect = Rect {
782            x: area.x,
783            y: top + i as u16,
784            width: area.width,
785            height: 1,
786        };
787        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
788    }
789
790    // Wordmark with a light sweep that travels across the letters.
791    let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
792    for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
793        let spans: Vec<Span> = text
794            .chars()
795            .enumerate()
796            .map(|(i, c)| {
797                let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
798                let style = if dist < 3 {
799                    Style::default()
800                        .fg(Color::White)
801                        .add_modifier(Modifier::BOLD)
802                } else {
803                    Style::default().fg(p.brand)
804                };
805                Span::styled(c.to_string(), style)
806            })
807            .collect();
808        let rect = Rect {
809            x: area.x,
810            y: top + row,
811            width: area.width,
812            height: 1,
813        };
814        f.render_widget(
815            Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
816            rect,
817        );
818    }
819
820    let tagline = Line::from(vec![
821        Span::styled("the ", Style::default().fg(p.dim)),
822        Span::styled(
823            "Lakehouse",
824            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
825        ),
826        Span::styled(" in your terminal", Style::default().fg(p.dim)),
827    ]);
828    let version = Line::from(Span::styled(
829        format!("v{}", env!("CARGO_PKG_VERSION")),
830        Style::default().fg(p.dim),
831    ));
832    for (offset, line) in [(7u16, tagline), (8u16, version)] {
833        let rect = Rect {
834            x: area.x,
835            y: top + offset,
836            width: area.width,
837            height: 1,
838        };
839        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
840    }
841}
842
843fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
844    let selected = app.picker.unwrap_or(0);
845    let width = (app
846        .profiles
847        .iter()
848        .map(|s| s.len())
849        .max()
850        .unwrap_or(10)
851        .max(20) as u16
852        + 6)
853    .min(area.width);
854    let height = (app.profiles.len() as u16 + 2).min(area.height);
855    let popup = Rect {
856        x: area.x + (area.width.saturating_sub(width)) / 2,
857        y: area.y + (area.height.saturating_sub(height)) / 2,
858        width,
859        height,
860    };
861    f.render_widget(Clear, popup);
862    let block = Block::default()
863        .title(Line::from(Span::styled(
864            " ⌂ Workspace ",
865            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
866        )))
867        .borders(Borders::ALL)
868        .border_type(BorderType::Rounded)
869        .border_style(Style::default().fg(p.key))
870        .padding(Padding::horizontal(1));
871    let items: Vec<ListItem> = app
872        .profiles
873        .iter()
874        .map(|name| {
875            let current = app.profile.as_deref() == Some(name.as_str());
876            let marker = if current { "● " } else { "  " };
877            ListItem::new(Line::from(vec![
878                Span::styled(marker, Style::default().fg(p.ok)),
879                Span::styled(name.as_str(), Style::default().fg(p.text)),
880            ]))
881        })
882        .collect();
883    let list = List::new(items)
884        .block(block)
885        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
886    let mut state = ListState::default().with_selected(Some(selected));
887    f.render_stateful_widget(list, popup, &mut state);
888}
889
890fn bucket_color(bucket: &str, p: &Palette) -> Color {
891    match bucket {
892        "Jobs" => p.jobs,
893        "SQL" => p.warehouses,
894        "All-Purpose" => p.clusters,
895        "DLT" => p.pipelines,
896        _ => p.dim,
897    }
898}
899
900fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
901    let cv = app.cost.as_ref().unwrap();
902    let scope = match &cv.data {
903        Some(Ok(d)) if d.scoped => " · this workspace",
904        Some(Ok(_)) => " · all workspaces",
905        _ => "",
906    };
907    let title = Line::from(vec![
908        Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
909        Span::styled(
910            format!("Usage · last 14 days{scope} "),
911            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
912        ),
913        Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
914    ]);
915    let block = Block::default()
916        .title(title)
917        .borders(Borders::ALL)
918        .border_type(BorderType::Rounded)
919        .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
920        .padding(Padding::new(1, 1, 1, 1));
921
922    match &cv.data {
923        None => {
924            let par = Paragraph::new(format!(
925                "{} querying system.billing.usage — the warehouse may need to start…",
926                app.spinner()
927            ))
928            .style(Style::default().fg(p.warn))
929            .block(block);
930            f.render_widget(par, area);
931        }
932        Some(Err(e)) => {
933            let par = Paragraph::new(format!(
934                "✗ {e}\n\nsystem tables need to be enabled and readable \
935                 (grants on the `system` catalog)"
936            ))
937            .style(Style::default().fg(p.err))
938            .wrap(Wrap { trim: false })
939            .block(block);
940            f.render_widget(par, area);
941        }
942        Some(Ok(data)) if data.days.is_empty() => {
943            let par = Paragraph::new("∅ no usage recorded in the last 14 days")
944                .style(Style::default().fg(p.dim))
945                .block(block);
946            f.render_widget(par, area);
947        }
948        Some(Ok(data)) => {
949            let mut lines: Vec<Line> = Vec::new();
950
951            // Legend with per-bucket totals, largest first.
952            let mut legend = vec![Span::raw("")];
953            for (bucket, total, usd) in &data.buckets {
954                legend.push(Span::styled(
955                    "■ ",
956                    Style::default().fg(bucket_color(bucket, p)),
957                ));
958                let amount = if data.priced {
959                    format!("{bucket} {total:.1} (${usd:.0})   ")
960                } else {
961                    format!("{bucket} {total:.1}   ")
962                };
963                legend.push(Span::styled(amount, Style::default().fg(p.text)));
964            }
965            let sigma = if data.priced {
966                format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
967            } else {
968                format!("Σ {:.1} DBU", data.total)
969            };
970            legend.push(Span::styled(
971                sigma,
972                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
973            ));
974            lines.push(Line::from(legend));
975            if data.priced {
976                lines.push(Line::from(Span::styled(
977                    " list prices before discounts",
978                    Style::default().fg(p.dim),
979                )));
980            }
981            if !data.scoped {
982                lines.push(Line::from(Span::styled(
983                    " couldn't resolve this workspace's id — showing the whole account",
984                    Style::default().fg(p.warn),
985                )));
986            }
987            lines.push(Line::default());
988
989            let max_day = data
990                .days
991                .iter()
992                .map(|d| d.total)
993                .fold(0.0_f64, f64::max)
994                .max(f64::EPSILON);
995            let inner_w = area.width.saturating_sub(4) as usize;
996            let bar_w = inner_w.saturating_sub(22).max(10);
997
998            for day in &data.days {
999                let mut spans = vec![Span::styled(
1000                    // "2026-07-01" -> "07-01"
1001                    format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1002                    Style::default().fg(p.dim),
1003                )];
1004                for (bucket, value) in &day.by_bucket {
1005                    let chars = ((value / max_day) * bar_w as f64)
1006                        .round()
1007                        .max(if *value > 0.0 { 1.0 } else { 0.0 })
1008                        as usize;
1009                    spans.push(Span::styled(
1010                        "█".repeat(chars),
1011                        Style::default().fg(bucket_color(bucket, p)),
1012                    ));
1013                }
1014                let day_label = if data.priced {
1015                    format!("  {:.1} · ${:.2}", day.total, day.total_usd)
1016                } else {
1017                    format!("  {:.1}", day.total)
1018                };
1019                spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1020                lines.push(Line::from(spans));
1021            }
1022
1023            if !data.spenders.is_empty() {
1024                lines.push(Line::default());
1025                lines.push(Line::from(Span::styled(
1026                    "TOP SPENDERS · 14 days",
1027                    Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1028                )));
1029                let name_w = data
1030                    .spenders
1031                    .iter()
1032                    .map(|s| {
1033                        app.resource_name(&s.kind, &s.id)
1034                            .unwrap_or_else(|| s.id.clone())
1035                            .chars()
1036                            .count()
1037                    })
1038                    .max()
1039                    .unwrap_or(0)
1040                    .max(8);
1041                for (i, s) in data.spenders.iter().enumerate() {
1042                    let name = app
1043                        .resource_name(&s.kind, &s.id)
1044                        .unwrap_or_else(|| s.id.clone());
1045                    let kind_color = match s.kind.as_str() {
1046                        "job" => p.jobs,
1047                        "cluster" => p.clusters,
1048                        "warehouse" => p.warehouses,
1049                        _ => p.dim,
1050                    };
1051                    let amount = if data.priced {
1052                        format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1053                    } else {
1054                        format!("{:>8.1} DBU", s.dbus)
1055                    };
1056                    lines.push(Line::from(vec![
1057                        Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1058                        Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1059                        Span::styled(
1060                            format!("{:<width$}", name, width = name_w + 2),
1061                            Style::default().fg(p.text),
1062                        ),
1063                        Span::styled(amount, Style::default().fg(p.text)),
1064                    ]));
1065                }
1066            }
1067
1068            let par = Paragraph::new(lines).block(block);
1069            f.render_widget(par, area);
1070        }
1071    }
1072}
1073
1074fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1075    let console = app.sql.as_ref().unwrap();
1076    let parts = Layout::default()
1077        .direction(Direction::Vertical)
1078        .constraints([Constraint::Length(3), Constraint::Min(0)])
1079        .split(area);
1080
1081    let via = if console.warehouse.is_empty() {
1082        String::new()
1083    } else {
1084        format!("via {} ", console.warehouse)
1085    };
1086    let prompt_block = Block::default()
1087        .title(Line::from(vec![
1088            Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1089            Span::styled(
1090                "SQL Console ",
1091                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1092            ),
1093            Span::styled(via, Style::default().fg(p.dim)),
1094        ]))
1095        .borders(Borders::ALL)
1096        .border_type(BorderType::Thick)
1097        .border_style(
1098            Style::default()
1099                .fg(p.warehouses)
1100                .add_modifier(Modifier::BOLD),
1101        )
1102        .padding(Padding::horizontal(1));
1103    if let Some((query, _)) = &app.hist_search {
1104        // Readline-style incremental search over history.
1105        let matched = app
1106            .hist_search_current()
1107            .map(|s| s.replace('\n', " "))
1108            .unwrap_or_default();
1109        let prompt = Paragraph::new(Line::from(vec![
1110            Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1111            Span::styled(
1112                query.as_str(),
1113                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1114            ),
1115            Span::styled("`: ", Style::default().fg(p.dim)),
1116            Span::styled(matched, Style::default().fg(p.text)),
1117        ]))
1118        .block(prompt_block);
1119        f.render_widget(prompt, parts[0]);
1120    } else {
1121        // Caret sits at the cursor, not the end; long inputs scroll so
1122        // the caret stays visible. Newlines from $EDITOR render as
1123        // spaces (byte-for-byte, so the caret math holds).
1124        let caret_byte = console
1125            .input
1126            .char_indices()
1127            .nth(console.cursor)
1128            .map(|(i, _)| i)
1129            .unwrap_or(console.input.len());
1130        let (before, after) = console.input.split_at(caret_byte);
1131        let inner_w = parts[0].width.saturating_sub(4) as usize; // borders + padding
1132        let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1133        let prompt = Paragraph::new(Line::from(vec![
1134            Span::styled("❯ ", Style::default().fg(p.key)),
1135            Span::styled(before.replace('\n', " "), Style::default().fg(p.text)),
1136            Span::styled("▏", Style::default().fg(p.warn)),
1137            Span::styled(after.replace('\n', " "), Style::default().fg(p.text)),
1138        ]))
1139        .scroll((0, hscroll))
1140        .block(prompt_block);
1141        f.render_widget(prompt, parts[0]);
1142    }
1143
1144    let row_info = match &console.data {
1145        Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1146        _ => String::new(),
1147    };
1148    let results_block = Block::default()
1149        .title(Line::from(vec![
1150            Span::styled(" Results ", Style::default().fg(p.text)),
1151            Span::styled(row_info, Style::default().fg(p.dim)),
1152        ]))
1153        .borders(Borders::ALL)
1154        .border_type(BorderType::Rounded)
1155        .border_style(Style::default().fg(p.border))
1156        .padding(Padding::horizontal(1));
1157
1158    if console.running {
1159        let par = Paragraph::new(format!(
1160            "{} running on {} — the warehouse may need a moment to start…",
1161            app.spinner(),
1162            console.warehouse
1163        ))
1164        .style(Style::default().fg(p.warn))
1165        .block(results_block);
1166        f.render_widget(par, parts[1]);
1167        return;
1168    }
1169    match &console.data {
1170        None => {
1171            let par = Paragraph::new(
1172                "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1173            )
1174            .style(Style::default().fg(p.dim))
1175            .block(results_block);
1176            f.render_widget(par, parts[1]);
1177        }
1178        Some(Err(e)) => {
1179            let par = Paragraph::new(format!("✗ {e}"))
1180                .style(Style::default().fg(p.err))
1181                .wrap(Wrap { trim: false })
1182                .block(results_block);
1183            f.render_widget(par, parts[1]);
1184        }
1185        Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1186            let par = Paragraph::new("✓ statement succeeded — no result set")
1187                .style(Style::default().fg(p.ok))
1188                .block(results_block);
1189            f.render_widget(par, parts[1]);
1190        }
1191        Some(Ok(data)) => {
1192            let header_cells: Vec<Cell> = data
1193                .headers
1194                .iter()
1195                .map(|h| {
1196                    Cell::from(h.as_str()).style(
1197                        Style::default()
1198                            .fg(p.warehouses)
1199                            .add_modifier(Modifier::BOLD),
1200                    )
1201                })
1202                .collect();
1203            let header = Row::new(header_cells);
1204            let rows: Vec<Row> = data
1205                .rows
1206                .iter()
1207                .skip(console.scroll)
1208                .map(|r| {
1209                    Row::new(
1210                        r.iter()
1211                            .map(|c| Cell::from(c.as_str()).style(Style::default().fg(p.text)))
1212                            .collect::<Vec<_>>(),
1213                    )
1214                })
1215                .collect();
1216            let n = data.headers.len().max(1) as u16;
1217            let widths: Vec<Constraint> = data
1218                .headers
1219                .iter()
1220                .map(|_| Constraint::Ratio(1, n as u32))
1221                .collect();
1222            let table = Table::new(rows, widths)
1223                .header(header)
1224                .column_spacing(1)
1225                .block(results_block);
1226            f.render_widget(table, parts[1]);
1227        }
1228    }
1229}
1230
1231fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1232    let pv = app.preview.as_ref().unwrap();
1233    let acc = p.catalog;
1234    let row_info = match &pv.data {
1235        Some(Ok(t)) => format!(" · {} rows", t.rows.len()),
1236        _ => String::new(),
1237    };
1238    let title = Line::from(vec![
1239        Span::styled(" ◫ ", Style::default().fg(acc)),
1240        Span::styled(
1241            format!("{}{} · preview ", pv.name, row_info),
1242            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1243        ),
1244        Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1245    ]);
1246    let block = Block::default()
1247        .title(title)
1248        .borders(Borders::ALL)
1249        .border_type(BorderType::Rounded)
1250        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1251        .padding(Padding::horizontal(1));
1252
1253    match &pv.data {
1254        None => {
1255            let par = Paragraph::new(format!(
1256                "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1257                app.spinner()
1258            ))
1259            .style(Style::default().fg(p.warn))
1260            .block(block);
1261            f.render_widget(par, area);
1262        }
1263        Some(Err(e)) => {
1264            let text = format!(
1265                "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1266                pv.warehouse,
1267                pv.warehouse_id,
1268                app.profile.as_deref().unwrap_or("default"),
1269                app.host.as_deref().unwrap_or("unknown"),
1270            );
1271            let par = Paragraph::new(text)
1272                .style(Style::default().fg(p.err))
1273                .wrap(Wrap { trim: false })
1274                .block(block);
1275            f.render_widget(par, area);
1276        }
1277        Some(Ok(data)) => {
1278            let header_cells: Vec<Cell> = data
1279                .headers
1280                .iter()
1281                .map(|h| {
1282                    Cell::from(h.as_str())
1283                        .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1284                })
1285                .collect();
1286            let header = Row::new(header_cells);
1287            let rows: Vec<Row> = data
1288                .rows
1289                .iter()
1290                .skip(pv.scroll)
1291                .map(|r| {
1292                    Row::new(
1293                        r.iter()
1294                            .map(|c| Cell::from(c.as_str()).style(Style::default().fg(p.text)))
1295                            .collect::<Vec<_>>(),
1296                    )
1297                })
1298                .collect();
1299            let n = data.headers.len().max(1) as u16;
1300            let widths: Vec<Constraint> = data
1301                .headers
1302                .iter()
1303                .map(|_| Constraint::Ratio(1, n as u32))
1304                .collect();
1305            let table = Table::new(rows, widths)
1306                .header(header)
1307                .column_spacing(1)
1308                .block(block);
1309            f.render_widget(table, area);
1310        }
1311    }
1312}
1313
1314fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1315    let d = app.detail.as_ref().unwrap();
1316    let acc = accent(d.panel, p);
1317    let title = Line::from(vec![
1318        Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1319        Span::styled(
1320            format!("{} · {} ", d.panel.title(), d.name),
1321            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1322        ),
1323    ]);
1324    let block = Block::default()
1325        .title(title)
1326        .borders(Borders::ALL)
1327        .border_type(BorderType::Rounded)
1328        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1329        .padding(Padding::horizontal(1));
1330
1331    let Some(data) = &d.data else {
1332        let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1333            .style(Style::default().fg(p.warn))
1334            .block(block);
1335        f.render_widget(par, area);
1336        return;
1337    };
1338
1339    // Fall back to raw when there's nothing structured to show (e.g. errors).
1340    if d.show_raw || data.summary.is_empty() {
1341        let par = Paragraph::new(data.raw.as_str())
1342            .style(Style::default().fg(p.text))
1343            .wrap(Wrap { trim: false })
1344            .scroll((d.scroll, 0))
1345            .block(block);
1346        f.render_widget(par, area);
1347        return;
1348    }
1349
1350    let mut lines: Vec<Line> = data
1351        .summary
1352        .iter()
1353        .map(|(k, v)| {
1354            Line::from(vec![
1355                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1356                Span::styled(v.as_str(), Style::default().fg(p.text)),
1357            ])
1358        })
1359        .collect();
1360    if !data.activity.is_empty() {
1361        lines.push(Line::default());
1362        lines.push(Line::from(Span::styled(
1363            d.section,
1364            Style::default().fg(acc).add_modifier(Modifier::BOLD),
1365        )));
1366        for (status, text) in &data.activity {
1367            lines.push(Line::from(vec![
1368                Span::styled("● ", Style::default().fg(status_color(status, p))),
1369                Span::styled(text.as_str(), Style::default().fg(p.text)),
1370            ]));
1371        }
1372    }
1373    let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1374    f.render_widget(par, area);
1375}
1376
1377fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1378    let rv = app.run_view.as_ref().unwrap();
1379    let acc = accent(rv.panel, p);
1380    let noun = if rv.panel == Panel::Jobs {
1381        "run"
1382    } else {
1383        "update"
1384    };
1385    let mut title_spans = vec![
1386        Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1387        Span::styled(
1388            format!("{} · {noun} ", rv.owner_name),
1389            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1390        ),
1391    ];
1392    if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1393        title_spans.push(Span::styled(
1394            format!("{run_id} "),
1395            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1396        ));
1397        title_spans.push(Span::styled(
1398            format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1399            Style::default().fg(p.dim),
1400        ));
1401    }
1402    if rv.live {
1403        title_spans.push(Span::styled(
1404            format!("{} live ", app.spinner()),
1405            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1406        ));
1407    }
1408    let block = Block::default()
1409        .title(Line::from(title_spans))
1410        .borders(Borders::ALL)
1411        .border_type(BorderType::Rounded)
1412        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1413        .padding(Padding::horizontal(1));
1414
1415    let Some(data) = &rv.data else {
1416        let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1417            .style(Style::default().fg(p.warn))
1418            .block(block);
1419        f.render_widget(par, area);
1420        return;
1421    };
1422
1423    if rv.show_raw || data.summary.is_empty() {
1424        let par = Paragraph::new(data.raw.as_str())
1425            .style(Style::default().fg(p.text))
1426            .wrap(Wrap { trim: false })
1427            .scroll((rv.scroll, 0))
1428            .block(block);
1429        f.render_widget(par, area);
1430        return;
1431    }
1432
1433    let mut lines: Vec<Line> = data
1434        .summary
1435        .iter()
1436        .map(|(k, v)| {
1437            Line::from(vec![
1438                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1439                Span::styled(v.as_str(), Style::default().fg(p.text)),
1440            ])
1441        })
1442        .collect();
1443    if !data.activity.is_empty() {
1444        lines.push(Line::default());
1445        lines.push(Line::from(Span::styled(
1446            if rv.panel == Panel::Jobs {
1447                "Tasks"
1448            } else {
1449                "Event log"
1450            },
1451            Style::default().fg(acc).add_modifier(Modifier::BOLD),
1452        )));
1453        for (status, text) in &data.activity {
1454            // Error continuation lines carry their own ↳ marker.
1455            if text.starts_with("  ↳") {
1456                lines.push(Line::from(Span::styled(
1457                    text.as_str(),
1458                    Style::default().fg(p.err),
1459                )));
1460            } else {
1461                lines.push(Line::from(vec![
1462                    Span::styled("● ", Style::default().fg(status_color(status, p))),
1463                    Span::styled(text.as_str(), Style::default().fg(p.text)),
1464                ]));
1465            }
1466        }
1467    }
1468    let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
1469    f.render_widget(par, area);
1470}
1471
1472fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1473    let block = Block::default()
1474        .borders(Borders::ALL)
1475        .border_type(BorderType::Rounded)
1476        .border_style(Style::default().fg(p.border));
1477    let inner = block.inner(area);
1478    f.render_widget(block, area);
1479
1480    // Brand block: a two-tone brick mark, then DATABRICKS in the theme's
1481    // gradient with a bright shimmer sweeping across it while data loads,
1482    // then LAKEHOUSE letter-spaced in the accent color.
1483    let (from, to) = (p.grad_from, p.grad_to);
1484    let grad = |t: f32| {
1485        Color::Rgb(
1486            (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
1487            (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
1488            (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
1489        )
1490    };
1491    let mut left = vec![
1492        Span::styled(
1493            " ◢",
1494            Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
1495        ),
1496        Span::styled(
1497            "◤ ",
1498            Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
1499        ),
1500    ];
1501    let word: Vec<char> = "DATABRICKS".chars().collect();
1502    let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
1503    for (i, ch) in word.iter().enumerate() {
1504        let t = i as f32 / (word.len() - 1) as f32;
1505        let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
1506            Style::default().fg(p.text).add_modifier(Modifier::BOLD)
1507        } else {
1508            Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
1509        };
1510        left.push(Span::styled(ch.to_string(), style));
1511    }
1512    left.push(Span::raw(" "));
1513    for ch in "LAKEHOUSE".chars() {
1514        left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
1515    }
1516    left.push(Span::styled(
1517        format!("· v{}", env!("CARGO_PKG_VERSION")),
1518        Style::default().fg(p.dim),
1519    ));
1520    if let Some(profile) = &app.profile {
1521        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
1522        left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
1523        left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
1524    }
1525    if let Some(Shape::Badge(b)) = &app.user_badge {
1526        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
1527        left.push(Span::styled(
1528            format!("{} {}", b.label, b.value),
1529            Style::default().fg(p.key),
1530        ));
1531    }
1532    if app.zoomed {
1533        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
1534        left.push(Span::styled(
1535            format!("⛶ {}", app.focus.title()),
1536            Style::default()
1537                .fg(accent(app.focus, p))
1538                .add_modifier(Modifier::BOLD),
1539        ));
1540    }
1541
1542    // Workspace health at a glance: counts of items per status bucket.
1543    let (ok, pending, failed, idle) = app.status_counts();
1544    for (count, glyph, color) in [
1545        (ok, "●", p.ok),
1546        (pending, "◐", p.warn),
1547        (failed, "✗", p.err),
1548        (idle, "○", p.dim),
1549    ] {
1550        if count > 0 {
1551            left.push(Span::styled("  ", Style::default()));
1552            left.push(Span::styled(
1553                format!("{glyph} {count}"),
1554                Style::default().fg(color),
1555            ));
1556        }
1557    }
1558    f.render_widget(Paragraph::new(Line::from(left)), inner);
1559
1560    let right = if app.loading {
1561        Line::from(Span::styled(
1562            format!("{} refreshing ", app.spinner()),
1563            Style::default().fg(p.warn),
1564        ))
1565    } else {
1566        Line::from(Span::styled(
1567            format!("updated {}s ago ", app.last_refresh_age().as_secs()),
1568            Style::default().fg(p.dim),
1569        ))
1570    };
1571    f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
1572}
1573
1574fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1575    let key =
1576        |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
1577    let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
1578
1579    if let Some(confirm) = &app.confirm {
1580        let line = Line::from(vec![
1581            Span::styled(
1582                format!(" ⚠ {} ", confirm.message),
1583                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1584            ),
1585            key("y"),
1586            dim(" confirm · any other key cancels"),
1587        ]);
1588        f.render_widget(Paragraph::new(line), area);
1589        return;
1590    }
1591
1592    if app.filter_entry {
1593        let line = Line::from(vec![
1594            Span::styled(
1595                format!(" /{}", app.active_filter()),
1596                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1597            ),
1598            Span::styled("▏", Style::default().fg(p.warn)),
1599            dim("  type to filter   "),
1600            key("enter"),
1601            dim(" apply   "),
1602            key("esc"),
1603            dim(" clear"),
1604        ]);
1605        f.render_widget(Paragraph::new(line), area);
1606        return;
1607    }
1608
1609    let mut spans = if app.help {
1610        vec![
1611            dim(" "),
1612            key("j"),
1613            dim("/"),
1614            key("k"),
1615            dim(" scroll   any other key closes"),
1616        ]
1617    } else if app.pane_cfg.is_some() {
1618        vec![
1619            dim(" "),
1620            key("j"),
1621            dim("/"),
1622            key("k"),
1623            dim(" select   "),
1624            key("space"),
1625            dim(" show/hide   "),
1626            key("J"),
1627            dim("/"),
1628            key("K"),
1629            dim(" move   "),
1630            key("esc"),
1631            dim(" done"),
1632        ]
1633    } else if app.jump.is_some() {
1634        vec![
1635            dim(" type to search everything   "),
1636            key("\u{2191}"),
1637            dim("/"),
1638            key("\u{2193}"),
1639            dim(" select   "),
1640            key("enter"),
1641            dim(" jump   "),
1642            key("esc"),
1643            dim(" close"),
1644        ]
1645    } else if app.problems.is_some() {
1646        vec![
1647            dim(" "),
1648            key("j"),
1649            dim("/"),
1650            key("k"),
1651            dim(" select   "),
1652            key("enter"),
1653            dim(" jump to item   "),
1654            key("esc"),
1655            dim(" close"),
1656        ]
1657    } else if app.cost.is_some() {
1658        vec![
1659            dim(" "),
1660            key("esc"),
1661            dim(" back   "),
1662            key("q"),
1663            dim(" quit"),
1664        ]
1665    } else if app.wh_picker.is_some() {
1666        vec![
1667            dim(" "),
1668            key("j"),
1669            dim("/"),
1670            key("k"),
1671            dim(" select   "),
1672            key("enter"),
1673            dim(" run preview   "),
1674            key("esc"),
1675            dim(" cancel"),
1676        ]
1677    } else if app.sql.is_some() {
1678        if app.hist_search.is_some() {
1679            vec![
1680                dim(" type to search history   "),
1681                key("^r"),
1682                dim(" older match   "),
1683                key("enter"),
1684                dim(" accept   "),
1685                key("esc"),
1686                dim(" cancel"),
1687            ]
1688        } else {
1689            vec![
1690                dim(" "),
1691                key("enter"),
1692                dim(" run   "),
1693                key("↑"),
1694                dim("/"),
1695                key("↓"),
1696                dim(" history   "),
1697                key("^r"),
1698                dim(" search   "),
1699                key("^x"),
1700                dim(" editor   "),
1701                key("pgup"),
1702                dim("/"),
1703                key("pgdn"),
1704                dim(" scroll   "),
1705                key("^s"),
1706                dim(" export   "),
1707                key("esc"),
1708                dim(if app.sql.as_ref().is_some_and(|c| c.running) {
1709                    " cancel query"
1710                } else {
1711                    " close"
1712                }),
1713            ]
1714        }
1715    } else if app.preview.is_some() {
1716        vec![
1717            dim(" "),
1718            key("esc"),
1719            dim(" back   "),
1720            key("j"),
1721            dim("/"),
1722            key("k"),
1723            dim(" scroll rows   "),
1724            key("e"),
1725            dim(" export csv   "),
1726            key("q"),
1727            dim(" quit"),
1728        ]
1729    } else if app.picker.is_some() {
1730        vec![
1731            dim(" "),
1732            key("j"),
1733            dim("/"),
1734            key("k"),
1735            dim(" select   "),
1736            key("enter"),
1737            dim(" switch workspace   "),
1738            key("esc"),
1739            dim(" cancel"),
1740        ]
1741    } else if app.run_view.is_some() {
1742        vec![
1743            dim(" "),
1744            key("esc"),
1745            dim(" back   "),
1746            key("h"),
1747            dim("/"),
1748            key("l"),
1749            dim(" older/newer   "),
1750            key("s"),
1751            dim(" cancel   "),
1752            key("j"),
1753            dim("/"),
1754            key("k"),
1755            dim(" scroll   "),
1756            key("J"),
1757            dim(" raw   "),
1758            key("q"),
1759            dim(" quit"),
1760        ]
1761    } else if app.detail.is_some() {
1762        let mut spans = vec![dim(" "), key("esc"), dim(" back   ")];
1763        match app.detail.as_ref() {
1764            Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
1765                spans.push(key("enter"));
1766                spans.push(dim(" latest run   "));
1767            }
1768            Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
1769                spans.push(key("enter"));
1770                spans.push(dim(" latest update   "));
1771            }
1772            _ => {}
1773        }
1774        spans.extend([
1775            key("j"),
1776            dim("/"),
1777            key("k"),
1778            dim(" scroll   "),
1779            key("J"),
1780            dim(" raw   "),
1781            key("o"),
1782            dim(" open   "),
1783            key("q"),
1784            dim(" quit"),
1785        ]);
1786        spans
1787    } else {
1788        let mut spans = vec![
1789            dim(" "),
1790            key("tab"),
1791            dim("/"),
1792            key("h"),
1793            dim("/"),
1794            key("l"),
1795            dim(" switch   "),
1796            key("j"),
1797            dim("/"),
1798            key("k"),
1799            dim(" select   "),
1800            key("/"),
1801            dim(if app.active_filter().is_empty() {
1802                " filter   "
1803            } else {
1804                " filter · esc clears   "
1805            }),
1806            key("enter"),
1807            dim(if app.focus == Panel::Catalog && app.uc_path.len() < 2 {
1808                " open   "
1809            } else {
1810                " details   "
1811            }),
1812        ];
1813        // Only show keys that do something in the focused pane.
1814        match app.focus {
1815            Panel::Catalog => {
1816                if !app.uc_path.is_empty() {
1817                    spans.push(key("bksp"));
1818                    spans.push(dim(" up   "));
1819                }
1820                if app.uc_path.len() == 2 {
1821                    spans.push(key("p"));
1822                    spans.push(dim(" preview   "));
1823                    spans.push(key("L"));
1824                    spans.push(dim(" lineage   "));
1825                    spans.push(key(":"));
1826                    spans.push(dim(" query table   "));
1827                }
1828            }
1829            Panel::Dashboards => {}
1830            _ => {
1831                spans.push(key("s"));
1832                spans.push(dim(" action   "));
1833            }
1834        }
1835        spans.extend([
1836            key("g"),
1837            dim(" access   "),
1838            key("$"),
1839            dim(" cost   "),
1840            key("!"),
1841            dim(" problems   "),
1842        ]);
1843        // At the catalog's table level the ':' hint already reads "query table".
1844        if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
1845            spans.push(key(":"));
1846            spans.push(dim(" sql   "));
1847        }
1848        spans.extend([
1849            key("o"),
1850            dim(" open   "),
1851            key("z"),
1852            dim(if app.zoomed { " unzoom   " } else { " zoom   " }),
1853            key("w"),
1854            dim(" workspace   "),
1855            key("t"),
1856            dim(" theme   "),
1857            key("r"),
1858            dim(" refresh   "),
1859            key("?"),
1860            dim(" help   "),
1861            key("q"),
1862            dim(" quit"),
1863        ]);
1864        spans
1865    };
1866    // A flash message prefixes the key hints instead of hiding them.
1867    if let Some((msg, _)) = &app.flash {
1868        let color = if msg.starts_with('✗') { p.err } else { p.ok };
1869        let mut prefixed = vec![
1870            Span::styled(
1871                format!(" {msg}"),
1872                Style::default().fg(color).add_modifier(Modifier::BOLD),
1873            ),
1874            dim("  ·  "),
1875        ];
1876        prefixed.append(&mut spans);
1877        spans = prefixed;
1878    }
1879    f.render_widget(Paragraph::new(Line::from(spans)), area);
1880}
1881
1882#[allow(clippy::too_many_arguments)]
1883fn draw_panel(
1884    f: &mut Frame,
1885    area: Rect,
1886    panel: Panel,
1887    shape: Option<&Shape>,
1888    focused: bool,
1889    selected: Option<usize>,
1890    fresh: bool,
1891    breadcrumb: &str,
1892    spinner: &str,
1893    filter: &str,
1894    entering: bool,
1895    p: &Palette,
1896) {
1897    let accent = accent(panel, p);
1898    let (border_style, title_style) = if focused {
1899        (
1900            Style::default().fg(accent).add_modifier(Modifier::BOLD),
1901            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1902        )
1903    } else {
1904        (Style::default().fg(p.border), Style::default().fg(p.dim))
1905    };
1906    let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
1907        Some(Shape::List(items)) => Some(
1908            items
1909                .iter()
1910                .filter(|it| crate::shape::item_matches(it, filter))
1911                .collect(),
1912        ),
1913        _ => None,
1914    };
1915    let count = match (&visible, shape) {
1916        // With a filter active, show how many made the cut.
1917        (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
1918            format!(" · {}/{}", v.len(), items.len())
1919        }
1920        (Some(v), _) => format!(" · {}", v.len()),
1921        (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
1922        _ => String::new(),
1923    };
1924    let crumb = if panel == Panel::Catalog && !breadcrumb.is_empty() {
1925        format!(" ▸ {breadcrumb}")
1926    } else {
1927        String::new()
1928    };
1929    // A short reversed flash on the title when fresh data lands.
1930    let title_style = if fresh {
1931        Style::default()
1932            .bg(accent)
1933            .fg(Color::Black)
1934            .add_modifier(Modifier::BOLD)
1935    } else {
1936        title_style
1937    };
1938    let mut title_spans = vec![
1939        Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
1940        Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
1941    ];
1942    if entering || !filter.is_empty() {
1943        title_spans.push(Span::styled(
1944            format!("/{}{} ", filter, if entering { "▏" } else { "" }),
1945            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1946        ));
1947    }
1948    let title = Line::from(title_spans);
1949    let block = Block::default()
1950        .title(title)
1951        .borders(Borders::ALL)
1952        .border_type(if focused {
1953            BorderType::Thick
1954        } else {
1955            BorderType::Rounded
1956        })
1957        .border_style(border_style)
1958        .padding(Padding::horizontal(1));
1959
1960    match shape {
1961        None => {
1962            let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
1963                .style(Style::default().fg(p.warn))
1964                .block(block);
1965            f.render_widget(par, area);
1966        }
1967        Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
1968            let empty = if !items.is_empty() {
1969                format!("∅ nothing matches /{filter}")
1970            } else {
1971                let msg = match panel {
1972                    Panel::Clusters => "no compute running",
1973                    Panel::Jobs => "no jobs defined",
1974                    Panel::Pipelines => "no pipelines",
1975                    Panel::Warehouses => "no warehouses",
1976                    Panel::Dashboards => "no dashboards yet",
1977                    Panel::Catalog => "nothing at this level",
1978                };
1979                format!("∅ {msg}")
1980            };
1981            let par = Paragraph::new(empty)
1982                .style(Style::default().fg(p.dim))
1983                .block(block);
1984            f.render_widget(par, area);
1985        }
1986        Some(Shape::List(_)) => {
1987            let list_items: Vec<ListItem> = visible
1988                .unwrap_or_default()
1989                .iter()
1990                .map(|item| {
1991                    let color = status_color(&item.status, p);
1992                    let chip = match item.status {
1993                        Status::Stopped | Status::Unknown(_) => Span::styled(
1994                            format!(" {} ", item.status.label()),
1995                            Style::default().fg(color).add_modifier(Modifier::DIM),
1996                        ),
1997                        _ => Span::styled(
1998                            format!(" {} ", item.status.label()),
1999                            Style::default()
2000                                .bg(color)
2001                                .fg(Color::Black)
2002                                .add_modifier(Modifier::BOLD),
2003                        ),
2004                    };
2005                    let mut spans = vec![
2006                        Span::styled("● ", Style::default().fg(color)),
2007                        Span::styled(item.name.as_str(), Style::default().fg(p.text)),
2008                        Span::raw("  "),
2009                        chip,
2010                    ];
2011                    if !item.history.is_empty() {
2012                        spans.push(Span::raw("  "));
2013                        for run in &item.history {
2014                            spans.push(Span::styled(
2015                                history_glyph(run),
2016                                Style::default().fg(status_color(run, p)),
2017                            ));
2018                        }
2019                    }
2020                    if let Some(detail) = &item.detail {
2021                        spans.push(Span::styled(
2022                            format!("  {}", detail),
2023                            Style::default().fg(p.dim),
2024                        ));
2025                    }
2026                    ListItem::new(Line::from(spans))
2027                })
2028                .collect();
2029            let list = List::new(list_items)
2030                .block(block)
2031                .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
2032            match selected {
2033                Some(sel) => {
2034                    let mut state = ListState::default().with_selected(Some(sel));
2035                    f.render_stateful_widget(list, area, &mut state);
2036                }
2037                None => f.render_widget(list, area),
2038            }
2039        }
2040        Some(Shape::Table(data)) => {
2041            let header_cells: Vec<Cell> = data
2042                .headers
2043                .iter()
2044                .map(|h| {
2045                    Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
2046                })
2047                .collect();
2048            let header = Row::new(header_cells).style(Style::default().fg(accent));
2049            let rows: Vec<Row> = data
2050                .rows
2051                .iter()
2052                .map(|r| Row::new(r.iter().map(|c| Cell::from(c.as_str())).collect::<Vec<_>>()))
2053                .collect();
2054            let widths: Vec<Constraint> = data
2055                .headers
2056                .iter()
2057                .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
2058                .collect();
2059            let table = Table::new(rows, widths).header(header).block(block);
2060            f.render_widget(table, area);
2061        }
2062        Some(Shape::Badge(b)) => {
2063            let text = format!("{}: {}", b.label, b.value);
2064            let par = Paragraph::new(text)
2065                .style(Style::default().fg(p.text))
2066                .block(block);
2067            f.render_widget(par, area);
2068        }
2069        Some(Shape::Text(t)) => {
2070            let color = if t.starts_with('✗') { p.err } else { p.text };
2071            let par = Paragraph::new(t.as_str())
2072                .style(Style::default().fg(color))
2073                .wrap(Wrap { trim: false })
2074                .block(block);
2075            f.render_widget(par, area);
2076        }
2077    }
2078}
2079
2080fn status_color(status: &Status, p: &Palette) -> Color {
2081    match status {
2082        Status::Running | Status::Success => p.ok,
2083        Status::Stopped => p.dim,
2084        Status::Pending => p.warn,
2085        Status::Failed => p.err,
2086        Status::Unknown(_) => p.text,
2087    }
2088}
2089
2090fn history_glyph(status: &Status) -> &'static str {
2091    match status {
2092        Status::Success => "✓",
2093        Status::Failed => "✗",
2094        Status::Running => "●",
2095        Status::Pending => "◐",
2096        Status::Stopped => "○",
2097        Status::Unknown(_) => "·",
2098    }
2099}