Skip to main content

databricks_tui/
ui.rs

1use crate::app::{App, Panel, ThemeMode};
2use crate::shape::{DetailData, Shape, Status, TableData};
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        Panel::Secrets => p.warn,
194    }
195}
196
197fn pane_breadcrumb(app: &App, panel: Panel) -> String {
198    match panel {
199        Panel::Catalog => app.uc_path.join("."),
200        Panel::Secrets => app.secret_scope.clone().unwrap_or_default(),
201        _ => String::new(),
202    }
203}
204
205pub fn draw(f: &mut Frame, app: &App) {
206    let p = palette(app.theme);
207
208    if app.splash_active() {
209        draw_splash(f, f.area(), app, &p);
210        return;
211    }
212
213    let root = Layout::default()
214        .direction(Direction::Vertical)
215        .constraints([
216            Constraint::Length(3),
217            Constraint::Min(0),
218            Constraint::Length(1),
219        ])
220        .split(f.area());
221
222    draw_header(f, root[0], app, &p);
223    draw_footer(f, root[2], app, &p);
224
225    if app.sql.is_some() {
226        draw_sql(f, root[1], app, &p);
227        if app.sql_complete.is_some() {
228            draw_sql_complete(f, root[1], app, &p);
229        }
230        // Running a query may pop the warehouse picker over the console.
231        if app.wh_picker.is_some() {
232            draw_wh_picker(f, root[1], app, &p);
233        }
234        return;
235    }
236
237    if app.cost.is_some() {
238        draw_cost(f, root[1], app, &p);
239        return;
240    }
241
242    if app.preview.is_some() {
243        draw_preview(f, root[1], app, &p);
244        return;
245    }
246
247    if app.run_view.is_some() {
248        draw_run(f, root[1], app, &p);
249        return;
250    }
251
252    if app.detail.is_some() {
253        draw_detail(f, root[1], app, &p);
254        return;
255    }
256
257    if app.zoomed {
258        let idx = Panel::ALL
259            .iter()
260            .position(|pn| pn == &app.focus)
261            .unwrap_or(0);
262        draw_panel(
263            f,
264            root[1],
265            app.focus,
266            app.shapes[idx].as_ref(),
267            true,
268            Some(app.selection(idx)),
269            false,
270            &pane_breadcrumb(app, app.focus),
271            app.spinner(),
272            &app.filters[idx],
273            app.filter_entry,
274            &p,
275        );
276        // Overlays must still render on top of the zoomed pane.
277        if app.picker.is_some() {
278            draw_picker(f, root[1], app, &p);
279        }
280        if app.wh_picker.is_some() {
281            draw_wh_picker(f, root[1], app, &p);
282        }
283        if app.problems.is_some() {
284            draw_problems(f, root[1], app, &p);
285        }
286        if app.upcoming.is_some() {
287            draw_upcoming(f, root[1], app, &p);
288        }
289        if app.jump.is_some() {
290            draw_jump(f, root[1], app, &p);
291        }
292        if app.pane_cfg.is_some() {
293            draw_pane_cfg(f, root[1], app, &p);
294        }
295        if app.secret_form.is_some() {
296            draw_secret_form(f, root[1], app, &p);
297        }
298        if app.help {
299            draw_help(f, root[1], app, &p);
300        }
301        return;
302    }
303
304    // The grid adapts to however many panes are visible, in the user's
305    // order: left column gets the first half (rounded up), right the rest.
306    let visible = app.visible_panes();
307    if visible.is_empty() {
308        let par = Paragraph::new("all panes hidden — press H to bring them back")
309            .style(Style::default().fg(p.dim))
310            .alignment(Alignment::Center);
311        f.render_widget(par, root[1]);
312    } else {
313        let rows_of = |n: usize| -> Vec<Constraint> {
314            (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
315        };
316        let left_count = visible.len().div_ceil(2);
317        let right_count = visible.len() - left_count;
318        let mut areas: Vec<Rect> = Vec::new();
319        if right_count == 0 {
320            areas.extend(
321                Layout::default()
322                    .direction(Direction::Vertical)
323                    .constraints(rows_of(left_count))
324                    .split(root[1])
325                    .iter(),
326            );
327        } else {
328            let body = Layout::default()
329                .direction(Direction::Horizontal)
330                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
331                .split(root[1]);
332            areas.extend(
333                Layout::default()
334                    .direction(Direction::Vertical)
335                    .constraints(rows_of(left_count))
336                    .split(body[0])
337                    .iter(),
338            );
339            areas.extend(
340                Layout::default()
341                    .direction(Direction::Vertical)
342                    .constraints(rows_of(right_count))
343                    .split(body[1])
344                    .iter(),
345            );
346        }
347
348        for (slot, &i) in visible.iter().enumerate() {
349            let panel = Panel::ALL[i];
350            let focused = app.focus == panel;
351            let shape = app.shapes[i].as_ref();
352            let selected = focused.then(|| app.selection(i));
353            let fresh = app.updated_at[i]
354                .map(|t| t.elapsed() < Duration::from_millis(1200))
355                .unwrap_or(false);
356            draw_panel(
357                f,
358                areas[slot],
359                panel,
360                shape,
361                focused,
362                selected,
363                fresh,
364                &pane_breadcrumb(app, panel),
365                app.spinner(),
366                &app.filters[i],
367                app.filter_entry && focused,
368                &p,
369            );
370        }
371    }
372
373    if app.picker.is_some() {
374        draw_picker(f, root[1], app, &p);
375    }
376    if app.wh_picker.is_some() {
377        draw_wh_picker(f, root[1], app, &p);
378    }
379    if app.problems.is_some() {
380        draw_problems(f, root[1], app, &p);
381    }
382    if app.upcoming.is_some() {
383        draw_upcoming(f, root[1], app, &p);
384    }
385    if app.jump.is_some() {
386        draw_jump(f, root[1], app, &p);
387    }
388    if app.pane_cfg.is_some() {
389        draw_pane_cfg(f, root[1], app, &p);
390    }
391    if app.secret_form.is_some() {
392        draw_secret_form(f, root[1], app, &p);
393    }
394    if app.help {
395        draw_help(f, root[1], app, &p);
396    }
397}
398
399fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
400    let Some(form) = &app.secret_form else {
401        return;
402    };
403    let width = 60.min(area.width.saturating_sub(4));
404    let height = 6.min(area.height);
405    let popup = Rect {
406        x: area.x + (area.width.saturating_sub(width)) / 2,
407        y: area.y + (area.height.saturating_sub(height)) / 2,
408        width,
409        height,
410    };
411    f.render_widget(Clear, popup);
412    let title = match &form.scope {
413        None => "New secret scope ".to_string(),
414        Some(s) => format!("New secret in {s} "),
415    };
416    let block = Block::default()
417        .title(Line::from(vec![
418            Span::styled(" ◈ ", Style::default().fg(p.warn)),
419            Span::styled(
420                title,
421                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
422            ),
423        ]))
424        .borders(Borders::ALL)
425        .border_type(BorderType::Thick)
426        .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
427        .padding(Padding::new(1, 1, 1, 0));
428    let label0 = if form.scope.is_none() {
429        "name "
430    } else {
431        "key  "
432    };
433    let mut lines = vec![Line::from(vec![
434        Span::styled(label0, Style::default().fg(p.dim)),
435        Span::styled(form.key.as_str(), Style::default().fg(p.text)),
436        if form.stage == 0 {
437            Span::styled("▏", Style::default().fg(p.warn))
438        } else {
439            Span::raw("")
440        },
441    ])];
442    if form.scope.is_some() {
443        // The value is never echoed — bullets only.
444        lines.push(Line::from(vec![
445            Span::styled("value ", Style::default().fg(p.dim)),
446            Span::styled(
447                "•".repeat(form.value.chars().count()),
448                Style::default().fg(p.text),
449            ),
450            if form.stage == 1 {
451                Span::styled("▏", Style::default().fg(p.warn))
452            } else {
453                Span::raw("")
454            },
455        ]));
456    }
457    f.render_widget(Paragraph::new(lines).block(block), popup);
458}
459
460fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
461    let Some(selected) = app.pane_cfg else {
462        return;
463    };
464    let width = 52.min(area.width.saturating_sub(4));
465    let height = 11.min(area.height);
466    let popup = Rect {
467        x: area.x + (area.width.saturating_sub(width)) / 2,
468        y: area.y + (area.height.saturating_sub(height)) / 2,
469        width,
470        height,
471    };
472    f.render_widget(Clear, popup);
473    let block = Block::default()
474        .title(Line::from(vec![
475            Span::styled(" ▦ ", Style::default().fg(p.key)),
476            Span::styled(
477                "Panes ",
478                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
479            ),
480        ]))
481        .borders(Borders::ALL)
482        .border_type(BorderType::Thick)
483        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
484        .padding(Padding::new(1, 1, 1, 0));
485    let items: Vec<ListItem> = app
486        .pane_order
487        .iter()
488        .map(|&i| {
489            let panel = Panel::ALL[i];
490            let (mark, style) = if app.hidden[i] {
491                ("○ ", Style::default().fg(p.dim))
492            } else {
493                ("● ", Style::default().fg(p.ok))
494            };
495            ListItem::new(Line::from(vec![
496                Span::styled(mark, style),
497                Span::styled(
498                    format!("{} ", panel.icon()),
499                    Style::default().fg(accent(panel, p)),
500                ),
501                Span::styled(
502                    panel.title(),
503                    if app.hidden[i] {
504                        Style::default().fg(p.dim)
505                    } else {
506                        Style::default().fg(p.text)
507                    },
508                ),
509            ]))
510        })
511        .collect();
512    let list = List::new(items)
513        .block(block)
514        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
515    let mut state = ListState::default().with_selected(Some(selected));
516    f.render_stateful_widget(list, popup, &mut state);
517}
518
519fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
520    let width = 74.min(area.width.saturating_sub(4));
521    let height = area.height.saturating_sub(2);
522    let popup = Rect {
523        x: area.x + (area.width.saturating_sub(width)) / 2,
524        y: area.y + 1,
525        width,
526        height,
527    };
528    f.render_widget(Clear, popup);
529    let block = Block::default()
530        .title(Line::from(vec![
531            Span::styled(" ? ", Style::default().fg(p.key)),
532            Span::styled(
533                "Keys ",
534                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
535            ),
536        ]))
537        .borders(Borders::ALL)
538        .border_type(BorderType::Thick)
539        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
540        .padding(Padding::new(1, 1, 1, 0));
541
542    let sections: &[(&str, &[(&str, &str)])] = &[
543        (
544            "Global",
545            &[
546                ("tab / l, shift+tab / h", "focus next / previous pane"),
547                ("j / k", "select item"),
548                ("enter", "open details / drill down"),
549                ("/", "filter the focused pane (esc clears)"),
550                ("ctrl+p", "command palette: fuzzy-jump anywhere"),
551                ("!", "problems: everything failing, enter jumps"),
552                ("u", "upcoming runs: what fires next, soonest first"),
553                (":", "SQL console (prefilled on a catalog table)"),
554                ("$", "cost view: DBUs, dollars, top spenders"),
555                ("H", "arrange panes: hide and reorder"),
556                ("z", "zoom the focused pane"),
557                ("w", "switch workspace profile"),
558                ("t", "cycle color theme"),
559                ("r", "refresh now"),
560                ("?", "this help"),
561                ("q / ctrl+c", "quit"),
562            ],
563        ),
564        (
565            "Focused pane",
566            &[
567                ("s", "start / stop / run the selected item"),
568                ("g", "access: grants and permissions"),
569                ("o", "open in the workspace web UI"),
570            ],
571        ),
572        (
573            "Unity Catalog pane",
574            &[
575                ("enter / backspace", "drill down / up (into volumes too)"),
576                ("p / P", "preview table rows (P picks the warehouse)"),
577                ("L", "lineage: upstream and downstream tables"),
578                (":", "query the selected table"),
579                ("enter on a file", "peek at its first 200 lines"),
580            ],
581        ),
582        (
583            "Details & runs",
584            &[
585                ("enter", "job/pipeline detail → latest run/update"),
586                ("h / l", "older / newer run"),
587                ("o", "full task output — keeps tailing while live"),
588                ("t", "timeline: per-task Gantt of the run"),
589                ("d", "dag: task dependency tree of the run"),
590                ("r", "repair a failed run — reruns only failed tasks"),
591                ("s", "cancel the shown run"),
592                ("j / k, J", "scroll · toggle raw JSON"),
593            ],
594        ),
595        (
596            "Table previews",
597            &[
598                ("j / k", "scroll rows"),
599                ("← / →", "page columns (wide tables)"),
600                ("/", "filter columns by name"),
601                ("v / enter", "record view: one row, fields stacked"),
602                ("e", "export rows to CSV"),
603            ],
604        ),
605        (
606            "Secret scopes pane",
607            &[
608                ("enter / backspace", "open a scope / back to scopes"),
609                ("a", "create a scope / add a secret (value masked)"),
610                ("x", "delete the selected scope or secret"),
611                ("g", "scope ACLs"),
612            ],
613        ),
614        (
615            "SQL console",
616            &[
617                ("enter", "run the statement"),
618                ("tab", "complete catalog/schema/table/column names"),
619                ("↑ / ↓, ctrl+r", "history · incremental search"),
620                ("ctrl+x", "compose in $EDITOR"),
621                ("ctrl+s", "export results to CSV"),
622                ("pgup / pgdn", "scroll results"),
623                ("shift+← / →", "page result columns"),
624                ("esc", "cancel a running query, else close"),
625            ],
626        ),
627    ];
628    let mut lines: Vec<Line> = Vec::new();
629    for (title, keys) in sections {
630        lines.push(Line::from(Span::styled(
631            *title,
632            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
633        )));
634        for (k, desc) in *keys {
635            lines.push(Line::from(vec![
636                Span::styled(format!("  {k:<24}"), Style::default().fg(p.warn)),
637                Span::styled(*desc, Style::default().fg(p.text)),
638            ]));
639        }
640        lines.push(Line::default());
641    }
642    let par = Paragraph::new(lines)
643        .scroll((app.help_scroll, 0))
644        .block(block);
645    f.render_widget(par, popup);
646}
647
648fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
649    let Some(jump) = &app.jump else {
650        return;
651    };
652    let matches = app.jump_matches();
653    let width = 70.min(area.width.saturating_sub(4));
654    let height = (matches.len() as u16 + 5).min(area.height);
655    let popup = Rect {
656        x: area.x + (area.width.saturating_sub(width)) / 2,
657        y: area.y + 2.min(area.height.saturating_sub(height)),
658        width,
659        height,
660    };
661    f.render_widget(Clear, popup);
662    let block = Block::default()
663        .title(Line::from(vec![
664            Span::styled(" ⌕ ", Style::default().fg(p.key)),
665            Span::styled(
666                "Jump to ",
667                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
668            ),
669        ]))
670        .borders(Borders::ALL)
671        .border_type(BorderType::Thick)
672        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
673        .padding(Padding::horizontal(1));
674    let inner = block.inner(popup);
675    f.render_widget(block, popup);
676
677    let parts = Layout::default()
678        .direction(Direction::Vertical)
679        .constraints([Constraint::Length(2), Constraint::Min(0)])
680        .split(inner);
681    f.render_widget(
682        Paragraph::new(Line::from(vec![
683            Span::styled("❯ ", Style::default().fg(p.key)),
684            Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
685            Span::styled("▏", Style::default().fg(p.warn)),
686        ])),
687        parts[0],
688    );
689
690    if matches.is_empty() {
691        f.render_widget(
692            Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
693            parts[1],
694        );
695        return;
696    }
697    let items: Vec<ListItem> = matches
698        .iter()
699        .map(|(panel_idx, name, label)| {
700            let panel = Panel::ALL[*panel_idx];
701            ListItem::new(Line::from(vec![
702                Span::styled(
703                    format!("{} ", panel.icon()),
704                    Style::default().fg(accent(panel, p)),
705                ),
706                Span::styled(format!("{name}  "), Style::default().fg(p.text)),
707                Span::styled(label.clone(), Style::default().fg(p.dim)),
708            ]))
709        })
710        .collect();
711    let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
712    let mut state = ListState::default().with_selected(Some(jump.index));
713    f.render_stateful_widget(list, parts[1], &mut state);
714}
715
716fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
717    let Some(pr) = &app.problems else {
718        return;
719    };
720    let truncate = |s: &str, max: usize| -> String {
721        if s.chars().count() <= max {
722            s.to_string()
723        } else {
724            let cut: String = s.chars().take(max.saturating_sub(1)).collect();
725            format!("{cut}…")
726        }
727    };
728    // Names get a bounded column; notes (which can be whole CLI error
729    // lines) take whatever room is left and are truncated to fit.
730    let name_w = pr
731        .items
732        .iter()
733        .map(|i| i.name.chars().count())
734        .max()
735        .unwrap_or(10)
736        .clamp(10, 40);
737    let note_w = pr
738        .items
739        .iter()
740        .map(|i| i.note.chars().count())
741        .max()
742        .unwrap_or(0);
743    // dot(2) + name + gap(2) + icon+title(20) + note + padding/borders(8)
744    let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
745    let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
746    // Room left for the note on each row inside borders and padding.
747    let note_space = (width as usize).saturating_sub(name_w + 30);
748    let popup = Rect {
749        x: area.x + (area.width.saturating_sub(width)) / 2,
750        y: area.y + (area.height.saturating_sub(height)) / 2,
751        width,
752        height,
753    };
754    f.render_widget(Clear, popup);
755    let block = Block::default()
756        .title(Line::from(vec![
757            Span::styled(" ✗ ", Style::default().fg(p.err)),
758            Span::styled(
759                format!("Problems · {} ", pr.items.len()),
760                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
761            ),
762        ]))
763        .borders(Borders::ALL)
764        .border_type(BorderType::Rounded)
765        .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
766        .padding(Padding::new(1, 1, 1, 1));
767
768    if pr.items.is_empty() {
769        let par = Paragraph::new("✓ all clear — nothing failing right now")
770            .style(Style::default().fg(p.ok))
771            .block(block);
772        f.render_widget(par, popup);
773        return;
774    }
775
776    let items: Vec<ListItem> = pr
777        .items
778        .iter()
779        .map(|problem| {
780            let panel = Panel::ALL[problem.panel];
781            ListItem::new(Line::from(vec![
782                Span::styled("✗ ", Style::default().fg(p.err)),
783                Span::styled(
784                    format!(
785                        "{:<width$}",
786                        truncate(&problem.name, name_w),
787                        width = name_w + 2
788                    ),
789                    Style::default().fg(p.text),
790                ),
791                Span::styled(
792                    format!("{} {:<18}", panel.icon(), panel.title()),
793                    Style::default().fg(accent(panel, p)),
794                ),
795                Span::styled(
796                    truncate(&problem.note, note_space),
797                    Style::default().fg(p.dim),
798                ),
799            ]))
800        })
801        .collect();
802    let list = List::new(items)
803        .block(block)
804        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
805    let mut state = ListState::default().with_selected(Some(pr.index));
806    f.render_stateful_widget(list, popup, &mut state);
807}
808
809/// Overlay listing every job that will run again on its own, soonest
810/// first: countdown, job name, and the schedule that drives it.
811fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
812    let Some(u) = &app.upcoming else {
813        return;
814    };
815    let label_w = u
816        .items
817        .iter()
818        .map(|i| i.next.label.chars().count())
819        .max()
820        .unwrap_or(8)
821        .clamp(8, 18);
822    let name_w = u
823        .items
824        .iter()
825        .map(|i| i.name.chars().count())
826        .max()
827        .unwrap_or(10)
828        .clamp(10, 40);
829    let desc_w = u
830        .items
831        .iter()
832        .map(|i| i.next.desc.chars().count())
833        .max()
834        .unwrap_or(0);
835    let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
836    let height = (u.items.len().max(1) as u16 + 4).min(area.height);
837    let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
838    let popup = Rect {
839        x: area.x + (area.width.saturating_sub(width)) / 2,
840        y: area.y + (area.height.saturating_sub(height)) / 2,
841        width,
842        height,
843    };
844    f.render_widget(Clear, popup);
845    let block = Block::default()
846        .title(Line::from(vec![
847            Span::styled(" ⏱ ", Style::default().fg(p.key)),
848            Span::styled(
849                format!("Upcoming runs · {} ", u.items.len()),
850                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
851            ),
852        ]))
853        .borders(Borders::ALL)
854        .border_type(BorderType::Rounded)
855        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
856        .padding(Padding::new(1, 1, 1, 1));
857
858    if u.loading {
859        let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
860            .style(Style::default().fg(p.warn))
861            .block(block);
862        f.render_widget(par, popup);
863        return;
864    }
865    if u.items.is_empty() {
866        let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
867            .style(Style::default().fg(p.dim))
868            .block(block);
869        f.render_widget(par, popup);
870        return;
871    }
872
873    let truncate = |s: &str, max: usize| -> String {
874        if s.chars().count() <= max {
875            s.to_string()
876        } else {
877            let cut: String = s.chars().take(max.saturating_sub(1)).collect();
878            format!("{cut}…")
879        }
880    };
881    let items: Vec<ListItem> = u
882        .items
883        .iter()
884        .map(|it| {
885            let when_color = if it.next.paused {
886                p.dim
887            } else if it.next.at_ms.is_some() {
888                p.ok
889            } else {
890                p.warn
891            };
892            ListItem::new(Line::from(vec![
893                Span::styled(
894                    format!(
895                        "{:<width$}",
896                        truncate(&it.next.label, label_w),
897                        width = label_w + 2
898                    ),
899                    Style::default().fg(when_color),
900                ),
901                Span::styled(
902                    format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
903                    Style::default().fg(p.text),
904                ),
905                Span::styled(
906                    truncate(&it.next.desc, desc_space),
907                    Style::default().fg(p.dim),
908                ),
909            ]))
910        })
911        .collect();
912    let list = List::new(items)
913        .block(block)
914        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
915    let mut state = ListState::default().with_selected(Some(u.index));
916    f.render_stateful_widget(list, popup, &mut state);
917}
918
919fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
920    let Some(picker) = &app.wh_picker else {
921        return;
922    };
923    let warehouses = app.warehouses();
924    let name_w = warehouses
925        .iter()
926        .map(|(n, _, _)| n.chars().count())
927        .max()
928        .unwrap_or(12)
929        .max(12);
930    // marker(2) + dot(2) + name + gap(2) + state(7) + gap(2) + id(16) + padding/borders(6)
931    let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
932    let height = (warehouses.len() as u16 + 4).min(area.height);
933    let popup = Rect {
934        x: area.x + (area.width.saturating_sub(width)) / 2,
935        y: area.y + (area.height.saturating_sub(height)) / 2,
936        width,
937        height,
938    };
939    f.render_widget(Clear, popup);
940    let block = Block::default()
941        .title(Line::from(vec![
942            Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
943            Span::styled(
944                "Run query on ",
945                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
946            ),
947        ]))
948        .borders(Borders::ALL)
949        .border_type(BorderType::Rounded)
950        .border_style(
951            Style::default()
952                .fg(p.warehouses)
953                .add_modifier(Modifier::BOLD),
954        )
955        .padding(Padding::new(1, 1, 1, 1));
956    let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
957    let items: Vec<ListItem> = warehouses
958        .iter()
959        .map(|(name, id, running)| {
960            let dot = if *running { p.ok } else { p.dim };
961            let marker = if current_id == Some(id.as_str()) {
962                "» "
963            } else {
964                "  "
965            };
966            let state = if *running { "running" } else { "idle   " };
967            ListItem::new(Line::from(vec![
968                Span::styled(marker, Style::default().fg(p.key)),
969                Span::styled("● ", Style::default().fg(dot)),
970                Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
971                Span::styled(
972                    format!("  {state}"),
973                    Style::default().fg(if *running { p.ok } else { p.dim }),
974                ),
975                Span::styled(format!("  {id}"), Style::default().fg(p.dim)),
976            ]))
977        })
978        .collect();
979    let list = List::new(items)
980        .block(block)
981        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
982    let mut state = ListState::default().with_selected(Some(picker.index));
983    f.render_stateful_widget(list, popup, &mut state);
984}
985
986const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
987const WORDMARK_BOT: &str = "█▄▀ █▀█  █  █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
988
989fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
990    let lines_total = 9u16;
991    let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
992    let frame = app.spinner_frame();
993
994    // Brick-mark pyramid in Databricks red.
995    let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
996    for (i, row) in bricks.iter().enumerate() {
997        let line = Line::from(Span::styled(
998            *row,
999            Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1000        ));
1001        let rect = Rect {
1002            x: area.x,
1003            y: top + i as u16,
1004            width: area.width,
1005            height: 1,
1006        };
1007        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1008    }
1009
1010    // Wordmark with a light sweep that travels across the letters.
1011    let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1012    for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1013        let spans: Vec<Span> = text
1014            .chars()
1015            .enumerate()
1016            .map(|(i, c)| {
1017                let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1018                let style = if dist < 3 {
1019                    Style::default()
1020                        .fg(Color::White)
1021                        .add_modifier(Modifier::BOLD)
1022                } else {
1023                    Style::default().fg(p.brand)
1024                };
1025                Span::styled(c.to_string(), style)
1026            })
1027            .collect();
1028        let rect = Rect {
1029            x: area.x,
1030            y: top + row,
1031            width: area.width,
1032            height: 1,
1033        };
1034        f.render_widget(
1035            Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1036            rect,
1037        );
1038    }
1039
1040    let tagline = Line::from(vec![
1041        Span::styled("the ", Style::default().fg(p.dim)),
1042        Span::styled(
1043            "Lakehouse",
1044            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1045        ),
1046        Span::styled(" in your terminal", Style::default().fg(p.dim)),
1047    ]);
1048    let version = Line::from(Span::styled(
1049        format!("v{}", env!("CARGO_PKG_VERSION")),
1050        Style::default().fg(p.dim),
1051    ));
1052    for (offset, line) in [(7u16, tagline), (8u16, version)] {
1053        let rect = Rect {
1054            x: area.x,
1055            y: top + offset,
1056            width: area.width,
1057            height: 1,
1058        };
1059        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1060    }
1061}
1062
1063fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1064    let selected = app.picker.unwrap_or(0);
1065    let width = (app
1066        .profiles
1067        .iter()
1068        .map(|s| s.len())
1069        .max()
1070        .unwrap_or(10)
1071        .max(20) as u16
1072        + 6)
1073    .min(area.width);
1074    let height = (app.profiles.len() as u16 + 2).min(area.height);
1075    let popup = Rect {
1076        x: area.x + (area.width.saturating_sub(width)) / 2,
1077        y: area.y + (area.height.saturating_sub(height)) / 2,
1078        width,
1079        height,
1080    };
1081    f.render_widget(Clear, popup);
1082    let block = Block::default()
1083        .title(Line::from(Span::styled(
1084            " ⌂ Workspace ",
1085            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1086        )))
1087        .borders(Borders::ALL)
1088        .border_type(BorderType::Rounded)
1089        .border_style(Style::default().fg(p.key))
1090        .padding(Padding::horizontal(1));
1091    let items: Vec<ListItem> = app
1092        .profiles
1093        .iter()
1094        .map(|name| {
1095            let current = app.profile.as_deref() == Some(name.as_str());
1096            let marker = if current { "● " } else { "  " };
1097            ListItem::new(Line::from(vec![
1098                Span::styled(marker, Style::default().fg(p.ok)),
1099                Span::styled(name.as_str(), Style::default().fg(p.text)),
1100            ]))
1101        })
1102        .collect();
1103    let list = List::new(items)
1104        .block(block)
1105        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1106    let mut state = ListState::default().with_selected(Some(selected));
1107    f.render_stateful_widget(list, popup, &mut state);
1108}
1109
1110fn bucket_color(bucket: &str, p: &Palette) -> Color {
1111    match bucket {
1112        "Jobs" => p.jobs,
1113        "SQL" => p.warehouses,
1114        "All-Purpose" => p.clusters,
1115        "DLT" => p.pipelines,
1116        _ => p.dim,
1117    }
1118}
1119
1120fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1121    let cv = app.cost.as_ref().unwrap();
1122    let scope = match &cv.data {
1123        Some(Ok(d)) if d.scoped => " · this workspace",
1124        Some(Ok(_)) => " · all workspaces",
1125        _ => "",
1126    };
1127    let title = Line::from(vec![
1128        Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1129        Span::styled(
1130            format!("Usage · last 14 days{scope} "),
1131            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1132        ),
1133        Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1134    ]);
1135    let block = Block::default()
1136        .title(title)
1137        .borders(Borders::ALL)
1138        .border_type(BorderType::Rounded)
1139        .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1140        .padding(Padding::new(1, 1, 1, 1));
1141
1142    match &cv.data {
1143        None => {
1144            let par = Paragraph::new(format!(
1145                "{} querying system.billing.usage — the warehouse may need to start…",
1146                app.spinner()
1147            ))
1148            .style(Style::default().fg(p.warn))
1149            .block(block);
1150            f.render_widget(par, area);
1151        }
1152        Some(Err(e)) => {
1153            let par = Paragraph::new(format!(
1154                "✗ {e}\n\nsystem tables need to be enabled and readable \
1155                 (grants on the `system` catalog)"
1156            ))
1157            .style(Style::default().fg(p.err))
1158            .wrap(Wrap { trim: false })
1159            .block(block);
1160            f.render_widget(par, area);
1161        }
1162        Some(Ok(data)) if data.days.is_empty() => {
1163            let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1164                .style(Style::default().fg(p.dim))
1165                .block(block);
1166            f.render_widget(par, area);
1167        }
1168        Some(Ok(data)) => {
1169            let mut lines: Vec<Line> = Vec::new();
1170
1171            // Legend with per-bucket totals, largest first.
1172            let mut legend = vec![Span::raw("")];
1173            for (bucket, total, usd) in &data.buckets {
1174                legend.push(Span::styled(
1175                    "■ ",
1176                    Style::default().fg(bucket_color(bucket, p)),
1177                ));
1178                let amount = if data.priced {
1179                    format!("{bucket} {total:.1} (${usd:.0})   ")
1180                } else {
1181                    format!("{bucket} {total:.1}   ")
1182                };
1183                legend.push(Span::styled(amount, Style::default().fg(p.text)));
1184            }
1185            let sigma = if data.priced {
1186                format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1187            } else {
1188                format!("Σ {:.1} DBU", data.total)
1189            };
1190            legend.push(Span::styled(
1191                sigma,
1192                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1193            ));
1194            lines.push(Line::from(legend));
1195            if data.priced {
1196                lines.push(Line::from(Span::styled(
1197                    " list prices before discounts",
1198                    Style::default().fg(p.dim),
1199                )));
1200            }
1201            if !data.scoped {
1202                lines.push(Line::from(Span::styled(
1203                    " couldn't resolve this workspace's id — showing the whole account",
1204                    Style::default().fg(p.warn),
1205                )));
1206            }
1207            lines.push(Line::default());
1208
1209            let max_day = data
1210                .days
1211                .iter()
1212                .map(|d| d.total)
1213                .fold(0.0_f64, f64::max)
1214                .max(f64::EPSILON);
1215            let inner_w = area.width.saturating_sub(4) as usize;
1216            let bar_w = inner_w.saturating_sub(22).max(10);
1217
1218            for day in &data.days {
1219                let mut spans = vec![Span::styled(
1220                    // "2026-07-01" -> "07-01"
1221                    format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1222                    Style::default().fg(p.dim),
1223                )];
1224                for (bucket, value) in &day.by_bucket {
1225                    let chars = ((value / max_day) * bar_w as f64)
1226                        .round()
1227                        .max(if *value > 0.0 { 1.0 } else { 0.0 })
1228                        as usize;
1229                    spans.push(Span::styled(
1230                        "█".repeat(chars),
1231                        Style::default().fg(bucket_color(bucket, p)),
1232                    ));
1233                }
1234                let day_label = if data.priced {
1235                    format!("  {:.1} · ${:.2}", day.total, day.total_usd)
1236                } else {
1237                    format!("  {:.1}", day.total)
1238                };
1239                spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1240                lines.push(Line::from(spans));
1241            }
1242
1243            if !data.spenders.is_empty() {
1244                lines.push(Line::default());
1245                lines.push(Line::from(Span::styled(
1246                    "TOP SPENDERS · 14 days",
1247                    Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1248                )));
1249                let name_w = data
1250                    .spenders
1251                    .iter()
1252                    .map(|s| {
1253                        app.resource_name(&s.kind, &s.id)
1254                            .unwrap_or_else(|| s.id.clone())
1255                            .chars()
1256                            .count()
1257                    })
1258                    .max()
1259                    .unwrap_or(0)
1260                    .max(8);
1261                for (i, s) in data.spenders.iter().enumerate() {
1262                    let name = app
1263                        .resource_name(&s.kind, &s.id)
1264                        .unwrap_or_else(|| s.id.clone());
1265                    let kind_color = match s.kind.as_str() {
1266                        "job" => p.jobs,
1267                        "cluster" => p.clusters,
1268                        "warehouse" => p.warehouses,
1269                        _ => p.dim,
1270                    };
1271                    let amount = if data.priced {
1272                        format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1273                    } else {
1274                        format!("{:>8.1} DBU", s.dbus)
1275                    };
1276                    lines.push(Line::from(vec![
1277                        Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1278                        Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1279                        Span::styled(
1280                            format!("{:<width$}", name, width = name_w + 2),
1281                            Style::default().fg(p.text),
1282                        ),
1283                        Span::styled(amount, Style::default().fg(p.text)),
1284                    ]));
1285                }
1286            }
1287
1288            let par = Paragraph::new(lines).block(block);
1289            f.render_widget(par, area);
1290        }
1291    }
1292}
1293
1294fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1295    let console = app.sql.as_ref().unwrap();
1296    let parts = Layout::default()
1297        .direction(Direction::Vertical)
1298        .constraints([Constraint::Length(3), Constraint::Min(0)])
1299        .split(area);
1300
1301    let via = if console.warehouse.is_empty() {
1302        String::new()
1303    } else {
1304        format!("via {} ", console.warehouse)
1305    };
1306    let prompt_block = Block::default()
1307        .title(Line::from(vec![
1308            Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1309            Span::styled(
1310                "SQL Console ",
1311                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1312            ),
1313            Span::styled(via, Style::default().fg(p.dim)),
1314        ]))
1315        .borders(Borders::ALL)
1316        .border_type(BorderType::Thick)
1317        .border_style(
1318            Style::default()
1319                .fg(p.warehouses)
1320                .add_modifier(Modifier::BOLD),
1321        )
1322        .padding(Padding::horizontal(1));
1323    if let Some((query, _)) = &app.hist_search {
1324        // Readline-style incremental search over history.
1325        let matched = app
1326            .hist_search_current()
1327            .map(|s| s.replace('\n', " "))
1328            .unwrap_or_default();
1329        let prompt = Paragraph::new(Line::from(vec![
1330            Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1331            Span::styled(
1332                query.as_str(),
1333                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1334            ),
1335            Span::styled("`: ", Style::default().fg(p.dim)),
1336            Span::styled(matched, Style::default().fg(p.text)),
1337        ]))
1338        .block(prompt_block);
1339        f.render_widget(prompt, parts[0]);
1340    } else {
1341        // Caret sits at the cursor, not the end; long inputs scroll so
1342        // the caret stays visible. Newlines from $EDITOR render as
1343        // spaces (byte-for-byte, so the caret math holds).
1344        let caret_byte = console
1345            .input
1346            .char_indices()
1347            .nth(console.cursor)
1348            .map(|(i, _)| i)
1349            .unwrap_or(console.input.len());
1350        let (before, after) = console.input.split_at(caret_byte);
1351        let inner_w = parts[0].width.saturating_sub(4) as usize; // borders + padding
1352        let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1353        let prompt = Paragraph::new(Line::from(vec![
1354            Span::styled("❯ ", Style::default().fg(p.key)),
1355            Span::styled(before.replace('\n', " "), Style::default().fg(p.text)),
1356            Span::styled("▏", Style::default().fg(p.warn)),
1357            Span::styled(after.replace('\n', " "), Style::default().fg(p.text)),
1358        ]))
1359        .scroll((0, hscroll))
1360        .block(prompt_block);
1361        f.render_widget(prompt, parts[0]);
1362    }
1363
1364    let row_info = match &console.data {
1365        Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1366        _ => String::new(),
1367    };
1368    let mut results_title = vec![
1369        Span::styled(" Results ", Style::default().fg(p.text)),
1370        Span::styled(row_info, Style::default().fg(p.dim)),
1371    ];
1372    // Wide results page horizontally; say where we are.
1373    let avail = parts[1].width.saturating_sub(4) as usize;
1374    let sliced = match &console.data {
1375        Some(Ok(t)) if !t.headers.is_empty() => {
1376            let cols: Vec<usize> = (0..t.headers.len()).collect();
1377            let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1378            if shown.len() < cols.len() {
1379                let first = console.col.min(cols.len() - 1) + 1;
1380                results_title.push(Span::styled(
1381                    format!(
1382                        "· cols {}–{} of {} (⇧←/→) ",
1383                        first,
1384                        first + shown.len() - 1,
1385                        cols.len()
1386                    ),
1387                    Style::default().fg(p.dim),
1388                ));
1389            }
1390            Some((shown, constraints))
1391        }
1392        _ => None,
1393    };
1394    let results_block = Block::default()
1395        .title(Line::from(results_title))
1396        .borders(Borders::ALL)
1397        .border_type(BorderType::Rounded)
1398        .border_style(Style::default().fg(p.border))
1399        .padding(Padding::horizontal(1));
1400
1401    if console.running {
1402        let par = Paragraph::new(format!(
1403            "{} running on {} — the warehouse may need a moment to start…",
1404            app.spinner(),
1405            console.warehouse
1406        ))
1407        .style(Style::default().fg(p.warn))
1408        .block(results_block);
1409        f.render_widget(par, parts[1]);
1410        return;
1411    }
1412    match &console.data {
1413        None => {
1414            let par = Paragraph::new(
1415                "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1416            )
1417            .style(Style::default().fg(p.dim))
1418            .block(results_block);
1419            f.render_widget(par, parts[1]);
1420        }
1421        Some(Err(e)) => {
1422            let par = Paragraph::new(format!("✗ {e}"))
1423                .style(Style::default().fg(p.err))
1424                .wrap(Wrap { trim: false })
1425                .block(results_block);
1426            f.render_widget(par, parts[1]);
1427        }
1428        Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1429            let par = Paragraph::new("✓ statement succeeded — no result set")
1430                .style(Style::default().fg(p.ok))
1431                .block(results_block);
1432            f.render_widget(par, parts[1]);
1433        }
1434        Some(Ok(data)) => {
1435            let (shown, constraints) = sliced.unwrap_or_default();
1436            let header_cells: Vec<Cell> = shown
1437                .iter()
1438                .map(|&i| {
1439                    Cell::from(data.headers[i].as_str()).style(
1440                        Style::default()
1441                            .fg(p.warehouses)
1442                            .add_modifier(Modifier::BOLD),
1443                    )
1444                })
1445                .collect();
1446            let header = Row::new(header_cells);
1447            let rows: Vec<Row> = data
1448                .rows
1449                .iter()
1450                .skip(console.scroll)
1451                .map(|r| {
1452                    Row::new(
1453                        shown
1454                            .iter()
1455                            .map(|&i| {
1456                                Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1457                                    .style(Style::default().fg(p.text))
1458                            })
1459                            .collect::<Vec<_>>(),
1460                    )
1461                })
1462                .collect();
1463            let table = Table::new(rows, constraints)
1464                .header(header)
1465                .column_spacing(1)
1466                .block(results_block);
1467            f.render_widget(table, parts[1]);
1468        }
1469    }
1470}
1471
1472/// Tab-completion popup, anchored under the segment being completed.
1473fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1474    let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1475        return;
1476    };
1477    // Mirror the prompt geometry from draw_sql to find the anchor column.
1478    let inner_w = area.width.saturating_sub(4) as usize;
1479    let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1480    let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1481
1482    let lines: Vec<Line> = if comp.loading {
1483        vec![Line::from(Span::styled(
1484            format!("{} fetching names…", app.spinner()),
1485            Style::default().fg(p.warn),
1486        ))]
1487    } else {
1488        // Keep the highlighted candidate inside the window.
1489        let visible = 8.min(comp.items.len());
1490        let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1491        comp.items
1492            .iter()
1493            .enumerate()
1494            .skip(skip)
1495            .take(visible)
1496            .map(|(i, item)| {
1497                let style = if i == comp.index {
1498                    Style::default()
1499                        .fg(p.key)
1500                        .add_modifier(Modifier::REVERSED | Modifier::BOLD)
1501                } else {
1502                    Style::default().fg(p.text)
1503                };
1504                Line::from(Span::styled(format!(" {item} "), style))
1505            })
1506            .collect()
1507    };
1508    let widest = comp
1509        .items
1510        .iter()
1511        .map(|i| i.chars().count())
1512        .max()
1513        .unwrap_or(16);
1514    let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
1515    let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
1516    let popup = Rect {
1517        x: anchor.min(area.x + area.width.saturating_sub(width)),
1518        y: area.y + 3,
1519        width,
1520        height,
1521    };
1522    let title = if comp.loading {
1523        String::new()
1524    } else {
1525        format!(" {}/{} ", comp.index + 1, comp.items.len())
1526    };
1527    let block = Block::default()
1528        .title(Span::styled(title, Style::default().fg(p.dim)))
1529        .borders(Borders::ALL)
1530        .border_type(BorderType::Rounded)
1531        .border_style(Style::default().fg(p.key));
1532    f.render_widget(Clear, popup);
1533    f.render_widget(Paragraph::new(lines).block(block), popup);
1534}
1535
1536/// Natural display width of each column in `cols`: the widest of header
1537/// and sampled cells, clamped so one huge value can't eat the pane.
1538fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
1539    cols.iter()
1540        .map(|&i| {
1541            let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
1542            for row in data.rows.iter().take(50) {
1543                if let Some(c) = row.get(i) {
1544                    w = w.max(c.chars().count());
1545                }
1546            }
1547            w.clamp(4, 36) as u16
1548        })
1549        .collect()
1550}
1551
1552/// The slice of `cols` that fits in `avail` starting at offset `col`,
1553/// with Length constraints — at least one column so the grid never
1554/// vanishes. Wide results page through here instead of squeezing every
1555/// column into an unreadable sliver.
1556fn grid_slice(
1557    data: &TableData,
1558    cols: &[usize],
1559    col: usize,
1560    avail: usize,
1561) -> (Vec<usize>, Vec<Constraint>) {
1562    let widths = grid_widths(data, cols);
1563    let col = col.min(cols.len().saturating_sub(1));
1564    let mut take = 0usize;
1565    let mut used = 0usize;
1566    for w in widths.iter().skip(col) {
1567        let next = used + *w as usize + if take > 0 { 1 } else { 0 };
1568        if take > 0 && next > avail {
1569            break;
1570        }
1571        used = next;
1572        take += 1;
1573    }
1574    let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
1575    let constraints: Vec<Constraint> = widths
1576        .iter()
1577        .skip(col)
1578        .take(take)
1579        .map(|&w| Constraint::Length(w))
1580        .collect();
1581    (shown, constraints)
1582}
1583
1584fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1585    let pv = app.preview.as_ref().unwrap();
1586    let acc = p.catalog;
1587    let mut title_spans: Vec<Span<'static>> = vec![
1588        Span::styled(" ◫ ", Style::default().fg(acc)),
1589        Span::styled(
1590            format!("{} · preview ", pv.name),
1591            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1592        ),
1593        Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1594    ];
1595    let make_block = |spans: Vec<Span<'static>>| {
1596        Block::default()
1597            .title(Line::from(spans))
1598            .borders(Borders::ALL)
1599            .border_type(BorderType::Rounded)
1600            .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1601            .padding(Padding::horizontal(1))
1602    };
1603
1604    match &pv.data {
1605        None => {
1606            let par = Paragraph::new(format!(
1607                "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1608                app.spinner()
1609            ))
1610            .style(Style::default().fg(p.warn))
1611            .block(make_block(title_spans));
1612            f.render_widget(par, area);
1613        }
1614        Some(Err(e)) => {
1615            let text = format!(
1616                "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1617                pv.warehouse,
1618                pv.warehouse_id,
1619                app.profile.as_deref().unwrap_or("default"),
1620                app.host.as_deref().unwrap_or("unknown"),
1621            );
1622            let par = Paragraph::new(text)
1623                .style(Style::default().fg(p.err))
1624                .wrap(Wrap { trim: false })
1625                .block(make_block(title_spans));
1626            f.render_widget(par, area);
1627        }
1628        Some(Ok(data)) => {
1629            title_spans.push(Span::styled(
1630                format!("· {} rows ", data.rows.len()),
1631                Style::default().fg(p.dim),
1632            ));
1633            let cols = pv.visible_cols();
1634            if !pv.filter.is_empty() {
1635                title_spans.push(Span::styled(
1636                    format!("· cols /{} ", pv.filter),
1637                    Style::default().fg(p.warn),
1638                ));
1639            }
1640            if cols.is_empty() {
1641                let par = Paragraph::new(format!(
1642                    "no columns match /{} — backspace to widen, esc to clear",
1643                    pv.filter
1644                ))
1645                .style(Style::default().fg(p.warn))
1646                .block(make_block(title_spans));
1647                f.render_widget(par, area);
1648                return;
1649            }
1650            if pv.record {
1651                // Transposed: one row, fields stacked — the readable way
1652                // through a table with hundreds of columns.
1653                let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
1654                title_spans.push(Span::styled(
1655                    format!("· row {}/{} ", row_n + 1, data.rows.len()),
1656                    Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1657                ));
1658                let name_w = cols
1659                    .iter()
1660                    .map(|&i| data.headers[i].chars().count())
1661                    .max()
1662                    .unwrap_or(0)
1663                    .min(32);
1664                let empty: Vec<String> = Vec::new();
1665                let row = data.rows.get(row_n).unwrap_or(&empty);
1666                let lines: Vec<Line> = cols
1667                    .iter()
1668                    .map(|&i| {
1669                        let value = row.get(i).map(String::as_str).unwrap_or("");
1670                        let vstyle = if value == "␀" {
1671                            Style::default().fg(p.dim)
1672                        } else {
1673                            Style::default().fg(p.text)
1674                        };
1675                        Line::from(vec![
1676                            Span::styled(
1677                                format!("{:<name_w$}  ", data.headers[i]),
1678                                Style::default().fg(acc),
1679                            ),
1680                            Span::styled(value.to_string(), vstyle),
1681                        ])
1682                    })
1683                    .collect();
1684                let par = Paragraph::new(lines)
1685                    .scroll((pv.rscroll, 0))
1686                    .block(make_block(title_spans));
1687                f.render_widget(par, area);
1688                return;
1689            }
1690            let avail = area.width.saturating_sub(4) as usize; // borders + padding
1691            let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
1692            if shown.len() < cols.len() {
1693                let first = pv.col.min(cols.len() - 1) + 1;
1694                title_spans.push(Span::styled(
1695                    format!(
1696                        "· cols {}–{} of {} ",
1697                        first,
1698                        first + shown.len() - 1,
1699                        cols.len()
1700                    ),
1701                    Style::default().fg(p.dim),
1702                ));
1703            }
1704            let header = Row::new(
1705                shown
1706                    .iter()
1707                    .map(|&i| {
1708                        Cell::from(data.headers[i].clone())
1709                            .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1710                    })
1711                    .collect::<Vec<_>>(),
1712            );
1713            let rows: Vec<Row> = data
1714                .rows
1715                .iter()
1716                .skip(pv.scroll)
1717                .map(|r| {
1718                    Row::new(
1719                        shown
1720                            .iter()
1721                            .map(|&i| {
1722                                Cell::from(r.get(i).cloned().unwrap_or_default())
1723                                    .style(Style::default().fg(p.text))
1724                            })
1725                            .collect::<Vec<_>>(),
1726                    )
1727                })
1728                .collect();
1729            let table = Table::new(rows, constraints)
1730                .header(header)
1731                .column_spacing(1)
1732                .block(make_block(title_spans));
1733            f.render_widget(table, area);
1734        }
1735    }
1736}
1737
1738fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1739    let d = app.detail.as_ref().unwrap();
1740    let acc = accent(d.panel, p);
1741    let title = Line::from(vec![
1742        Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1743        Span::styled(
1744            format!("{} · {} ", d.panel.title(), d.name),
1745            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1746        ),
1747    ]);
1748    let block = Block::default()
1749        .title(title)
1750        .borders(Borders::ALL)
1751        .border_type(BorderType::Rounded)
1752        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1753        .padding(Padding::horizontal(1));
1754
1755    let Some(data) = &d.data else {
1756        let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1757            .style(Style::default().fg(p.warn))
1758            .block(block);
1759        f.render_widget(par, area);
1760        return;
1761    };
1762
1763    // Fall back to raw when there's nothing structured to show (e.g. errors).
1764    if d.show_raw || data.summary.is_empty() {
1765        let par = Paragraph::new(data.raw.as_str())
1766            .style(Style::default().fg(p.text))
1767            .wrap(Wrap { trim: false })
1768            .scroll((d.scroll, 0))
1769            .block(block);
1770        f.render_widget(par, area);
1771        return;
1772    }
1773
1774    let mut lines: Vec<Line> = data
1775        .summary
1776        .iter()
1777        .map(|(k, v)| {
1778            Line::from(vec![
1779                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1780                Span::styled(v.as_str(), Style::default().fg(p.text)),
1781            ])
1782        })
1783        .collect();
1784    if !data.activity.is_empty() {
1785        lines.push(Line::default());
1786        lines.push(Line::from(Span::styled(
1787            d.section,
1788            Style::default().fg(acc).add_modifier(Modifier::BOLD),
1789        )));
1790        for (status, text) in &data.activity {
1791            // Lineage tree guides carry their own structure — no dot.
1792            let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
1793            if treeish {
1794                lines.push(Line::from(Span::styled(
1795                    text.as_str(),
1796                    Style::default().fg(status_color(status, p)),
1797                )));
1798            } else {
1799                lines.push(Line::from(vec![
1800                    Span::styled("● ", Style::default().fg(status_color(status, p))),
1801                    Span::styled(text.as_str(), Style::default().fg(p.text)),
1802                ]));
1803            }
1804        }
1805    }
1806    let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1807    f.render_widget(par, area);
1808}
1809
1810fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1811    let rv = app.run_view.as_ref().unwrap();
1812    let acc = accent(rv.panel, p);
1813    let noun = if rv.panel == Panel::Jobs {
1814        "run"
1815    } else {
1816        "update"
1817    };
1818    let mut title_spans = vec![
1819        Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1820        Span::styled(
1821            format!("{} · {noun} ", rv.owner_name),
1822            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1823        ),
1824    ];
1825    if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1826        title_spans.push(Span::styled(
1827            format!("{run_id} "),
1828            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1829        ));
1830        title_spans.push(Span::styled(
1831            format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1832            Style::default().fg(p.dim),
1833        ));
1834    }
1835    if rv.live {
1836        title_spans.push(Span::styled(
1837            format!("{} live ", app.spinner()),
1838            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1839        ));
1840    }
1841    if rv.show_output {
1842        title_spans.push(Span::styled(
1843            if rv.live {
1844                "· output (tailing) "
1845            } else {
1846                "· output "
1847            },
1848            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1849        ));
1850    }
1851    if rv.show_timeline {
1852        title_spans.push(Span::styled(
1853            "· timeline ",
1854            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1855        ));
1856    }
1857    if rv.show_dag {
1858        title_spans.push(Span::styled(
1859            "· dag ",
1860            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1861        ));
1862    }
1863    let block = Block::default()
1864        .title(Line::from(title_spans))
1865        .borders(Borders::ALL)
1866        .border_type(BorderType::Rounded)
1867        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1868        .padding(Padding::horizontal(1));
1869
1870    // Full task output/logs, layered over the run detail.
1871    if rv.show_output {
1872        let par = match &rv.output {
1873            Some(text) => Paragraph::new(text.as_str())
1874                .style(Style::default().fg(p.text))
1875                .wrap(Wrap { trim: false })
1876                .scroll((rv.scroll, 0))
1877                .block(block),
1878            None => Paragraph::new(format!(
1879                "{} fetching task output — one call per task…",
1880                app.spinner()
1881            ))
1882            .style(Style::default().fg(p.warn))
1883            .block(block),
1884        };
1885        f.render_widget(par, area);
1886        return;
1887    }
1888
1889    let Some(data) = &rv.data else {
1890        let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1891            .style(Style::default().fg(p.warn))
1892            .block(block);
1893        f.render_widget(par, area);
1894        return;
1895    };
1896
1897    if rv.show_timeline {
1898        draw_run_timeline(f, area, rv.scroll, data, block, p);
1899        return;
1900    }
1901
1902    if rv.show_dag {
1903        draw_run_dag(f, area, rv.scroll, data, block, p);
1904        return;
1905    }
1906
1907    if rv.show_raw || data.summary.is_empty() {
1908        let par = Paragraph::new(data.raw.as_str())
1909            .style(Style::default().fg(p.text))
1910            .wrap(Wrap { trim: false })
1911            .scroll((rv.scroll, 0))
1912            .block(block);
1913        f.render_widget(par, area);
1914        return;
1915    }
1916
1917    let mut lines: Vec<Line> = data
1918        .summary
1919        .iter()
1920        .map(|(k, v)| {
1921            Line::from(vec![
1922                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1923                Span::styled(v.as_str(), Style::default().fg(p.text)),
1924            ])
1925        })
1926        .collect();
1927    if !data.activity.is_empty() {
1928        lines.push(Line::default());
1929        lines.push(Line::from(Span::styled(
1930            if rv.panel == Panel::Jobs {
1931                "Tasks"
1932            } else {
1933                "Event log"
1934            },
1935            Style::default().fg(acc).add_modifier(Modifier::BOLD),
1936        )));
1937        for (status, text) in &data.activity {
1938            // Error continuation lines carry their own ↳ marker.
1939            if text.starts_with("  ↳") {
1940                lines.push(Line::from(Span::styled(
1941                    text.as_str(),
1942                    Style::default().fg(p.err),
1943                )));
1944            } else {
1945                lines.push(Line::from(vec![
1946                    Span::styled("● ", Style::default().fg(status_color(status, p))),
1947                    Span::styled(text.as_str(), Style::default().fg(p.text)),
1948                ]));
1949            }
1950        }
1951    }
1952    let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
1953    f.render_widget(par, area);
1954}
1955
1956/// Gantt chart of a job run: one bar per task on a shared time axis,
1957/// colored by state, so the long pole is visible at a glance.
1958fn draw_run_timeline(
1959    f: &mut Frame,
1960    area: Rect,
1961    scroll: u16,
1962    data: &DetailData,
1963    block: Block,
1964    p: &Palette,
1965) {
1966    let tasks = crate::fetchers::runs::timeline(&data.raw);
1967    let now = std::time::SystemTime::now()
1968        .duration_since(std::time::UNIX_EPOCH)
1969        .map(|d| d.as_millis() as u64)
1970        .unwrap_or(0);
1971    let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
1972    let Some(t0) = t0 else {
1973        let par = Paragraph::new(
1974            "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
1975        )
1976        .style(Style::default().fg(p.dim))
1977        .wrap(Wrap { trim: false })
1978        .block(block);
1979        f.render_widget(par, area);
1980        return;
1981    };
1982    let t1 = tasks
1983        .iter()
1984        .filter(|t| t.start > 0)
1985        .map(|t| t.end.unwrap_or(now).max(t.start))
1986        .max()
1987        .unwrap_or(t0);
1988    let span = (t1 - t0).max(1);
1989
1990    let name_w = tasks
1991        .iter()
1992        .map(|t| t.key.chars().count())
1993        .max()
1994        .unwrap_or(4)
1995        .clamp(4, 24);
1996    let inner_w = area.width.saturating_sub(4) as usize;
1997    // Room for the widest duration ("59m 59s…" = 8) after the bar.
1998    let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
1999
2000    let mut lines: Vec<Line> = vec![
2001        Line::from(Span::styled(
2002            format!(
2003                "◷ {} task{} · span {}",
2004                tasks.len(),
2005                if tasks.len() == 1 { "" } else { "s" },
2006                crate::shape::fmt_duration_ms(span)
2007            ),
2008            Style::default().fg(p.dim),
2009        )),
2010        Line::default(),
2011    ];
2012    for t in &tasks {
2013        let mut key: String = t.key.chars().take(name_w).collect();
2014        if key.len() < t.key.len() {
2015            key.pop();
2016            key.push('…');
2017        }
2018        let name = Span::styled(format!("{key:<name_w$}  "), Style::default().fg(p.text));
2019        if t.start == 0 {
2020            lines.push(Line::from(vec![
2021                name,
2022                Span::styled(
2023                    format!("– {}", t.status.label().to_lowercase()),
2024                    Style::default().fg(p.dim),
2025                ),
2026            ]));
2027            continue;
2028        }
2029        let end = t.end.unwrap_or(now).max(t.start);
2030        let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2031        let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2032        let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2033        let dur = format!(
2034            " {}{}",
2035            crate::shape::fmt_duration_ms(end - t.start),
2036            if t.end.is_none() { "…" } else { "" }
2037        );
2038        lines.push(Line::from(vec![
2039            name,
2040            Span::styled(" ".repeat(off), Style::default()),
2041            Span::styled(
2042                "█".repeat(len),
2043                Style::default().fg(status_color(&t.status, p)),
2044            ),
2045            Span::styled(dur, Style::default().fg(p.dim)),
2046        ]));
2047    }
2048    let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2049    f.render_widget(par, area);
2050}
2051
2052/// Dependency tree of a job run's tasks: each task under the task it
2053/// waits for, colored by state, with extra dependencies annotated.
2054fn draw_run_dag(
2055    f: &mut Frame,
2056    area: Rect,
2057    scroll: u16,
2058    data: &DetailData,
2059    block: Block,
2060    p: &Palette,
2061) {
2062    let rows = crate::fetchers::runs::dag(&data.raw);
2063    if rows.is_empty() {
2064        let par = Paragraph::new(
2065            "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
2066        )
2067        .style(Style::default().fg(p.dim))
2068        .wrap(Wrap { trim: false })
2069        .block(block);
2070        f.render_widget(par, area);
2071        return;
2072    }
2073    let mut lines: Vec<Line> = vec![
2074        Line::from(Span::styled(
2075            format!(
2076                "⧉ {} task{} · order of execution, top to bottom",
2077                rows.len(),
2078                if rows.len() == 1 { "" } else { "s" },
2079            ),
2080            Style::default().fg(p.dim),
2081        )),
2082        Line::default(),
2083    ];
2084    for r in &rows {
2085        let mut spans = vec![
2086            Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
2087            Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
2088            Span::styled(r.key.clone(), Style::default().fg(p.text)),
2089            Span::styled(
2090                format!("  {}", r.status.label().to_lowercase()),
2091                Style::default().fg(status_color(&r.status, p)),
2092            ),
2093        ];
2094        if let Some(d) = r.duration {
2095            spans.push(Span::styled(
2096                format!("  ·  {}", crate::shape::fmt_duration_ms(d)),
2097                Style::default().fg(p.dim),
2098            ));
2099        }
2100        if !r.also_after.is_empty() {
2101            spans.push(Span::styled(
2102                format!("  (also after {})", r.also_after.join(", ")),
2103                Style::default().fg(p.dim),
2104            ));
2105        }
2106        lines.push(Line::from(spans));
2107    }
2108    let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2109    f.render_widget(par, area);
2110}
2111
2112fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2113    let block = Block::default()
2114        .borders(Borders::ALL)
2115        .border_type(BorderType::Rounded)
2116        .border_style(Style::default().fg(p.border));
2117    let inner = block.inner(area);
2118    f.render_widget(block, area);
2119
2120    // Brand block: a two-tone brick mark, then DATABRICKS in the theme's
2121    // gradient with a bright shimmer sweeping across it while data loads,
2122    // then LAKEHOUSE letter-spaced in the accent color.
2123    let (from, to) = (p.grad_from, p.grad_to);
2124    let grad = |t: f32| {
2125        Color::Rgb(
2126            (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
2127            (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
2128            (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
2129        )
2130    };
2131    let mut left = vec![
2132        Span::styled(
2133            " ◢",
2134            Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
2135        ),
2136        Span::styled(
2137            "◤ ",
2138            Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
2139        ),
2140    ];
2141    let word: Vec<char> = "DATABRICKS".chars().collect();
2142    let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
2143    for (i, ch) in word.iter().enumerate() {
2144        let t = i as f32 / (word.len() - 1) as f32;
2145        let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
2146            Style::default().fg(p.text).add_modifier(Modifier::BOLD)
2147        } else {
2148            Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
2149        };
2150        left.push(Span::styled(ch.to_string(), style));
2151    }
2152    left.push(Span::raw(" "));
2153    for ch in "LAKEHOUSE".chars() {
2154        left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
2155    }
2156    left.push(Span::styled(
2157        format!("· v{}", env!("CARGO_PKG_VERSION")),
2158        Style::default().fg(p.dim),
2159    ));
2160    if let Some(profile) = &app.profile {
2161        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2162        left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
2163        left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
2164    }
2165    if let Some(Shape::Badge(b)) = &app.user_badge {
2166        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2167        left.push(Span::styled(
2168            format!("{} {}", b.label, b.value),
2169            Style::default().fg(p.key),
2170        ));
2171    }
2172    if app.zoomed {
2173        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2174        left.push(Span::styled(
2175            format!("⛶ {}", app.focus.title()),
2176            Style::default()
2177                .fg(accent(app.focus, p))
2178                .add_modifier(Modifier::BOLD),
2179        ));
2180    }
2181
2182    // Workspace health at a glance: counts of items per status bucket.
2183    let (ok, pending, failed, idle) = app.status_counts();
2184    for (count, glyph, color) in [
2185        (ok, "●", p.ok),
2186        (pending, "◐", p.warn),
2187        (failed, "✗", p.err),
2188        (idle, "○", p.dim),
2189    ] {
2190        if count > 0 {
2191            left.push(Span::styled("  ", Style::default()));
2192            left.push(Span::styled(
2193                format!("{glyph} {count}"),
2194                Style::default().fg(color),
2195            ));
2196        }
2197    }
2198    f.render_widget(Paragraph::new(Line::from(left)), inner);
2199
2200    let right = if app.loading {
2201        Line::from(Span::styled(
2202            format!("{} refreshing ", app.spinner()),
2203            Style::default().fg(p.warn),
2204        ))
2205    } else {
2206        Line::from(Span::styled(
2207            format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2208            Style::default().fg(p.dim),
2209        ))
2210    };
2211    f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2212}
2213
2214fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2215    let key =
2216        |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2217    let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2218
2219    if let Some(confirm) = &app.confirm {
2220        let line = Line::from(vec![
2221            Span::styled(
2222                format!(" ⚠ {} ", confirm.message),
2223                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2224            ),
2225            key("y"),
2226            dim(" confirm · any other key cancels"),
2227        ]);
2228        f.render_widget(Paragraph::new(line), area);
2229        return;
2230    }
2231
2232    if app.filter_entry {
2233        let line = Line::from(vec![
2234            Span::styled(
2235                format!(" /{}", app.active_filter()),
2236                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2237            ),
2238            Span::styled("▏", Style::default().fg(p.warn)),
2239            dim("  type to filter   "),
2240            key("enter"),
2241            dim(" apply   "),
2242            key("esc"),
2243            dim(" clear"),
2244        ]);
2245        f.render_widget(Paragraph::new(line), area);
2246        return;
2247    }
2248
2249    if let Some(pv) = &app.preview {
2250        if pv.filter_entry {
2251            let line = Line::from(vec![
2252                Span::styled(
2253                    format!(" cols /{}", pv.filter),
2254                    Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2255                ),
2256                Span::styled("▏", Style::default().fg(p.warn)),
2257                dim("  type to filter columns   "),
2258                key("enter"),
2259                dim(" apply   "),
2260                key("esc"),
2261                dim(" clear"),
2262            ]);
2263            f.render_widget(Paragraph::new(line), area);
2264            return;
2265        }
2266    }
2267
2268    let mut spans = if app.secret_form.is_some() {
2269        vec![
2270            dim(" type   "),
2271            key("enter"),
2272            dim(" next / save   "),
2273            key("esc"),
2274            dim(" cancel"),
2275        ]
2276    } else if app.help {
2277        vec![
2278            dim(" "),
2279            key("j"),
2280            dim("/"),
2281            key("k"),
2282            dim(" scroll   any other key closes"),
2283        ]
2284    } else if app.pane_cfg.is_some() {
2285        vec![
2286            dim(" "),
2287            key("j"),
2288            dim("/"),
2289            key("k"),
2290            dim(" select   "),
2291            key("space"),
2292            dim(" show/hide   "),
2293            key("J"),
2294            dim("/"),
2295            key("K"),
2296            dim(" move   "),
2297            key("esc"),
2298            dim(" done"),
2299        ]
2300    } else if app.jump.is_some() {
2301        vec![
2302            dim(" type to search everything   "),
2303            key("\u{2191}"),
2304            dim("/"),
2305            key("\u{2193}"),
2306            dim(" select   "),
2307            key("enter"),
2308            dim(" jump   "),
2309            key("esc"),
2310            dim(" close"),
2311        ]
2312    } else if app.problems.is_some() {
2313        vec![
2314            dim(" "),
2315            key("j"),
2316            dim("/"),
2317            key("k"),
2318            dim(" select   "),
2319            key("enter"),
2320            dim(" jump to item   "),
2321            key("esc"),
2322            dim(" close"),
2323        ]
2324    } else if app.upcoming.is_some() {
2325        vec![
2326            dim(" "),
2327            key("j"),
2328            dim("/"),
2329            key("k"),
2330            dim(" select   "),
2331            key("enter"),
2332            dim(" jump to job   "),
2333            key("esc"),
2334            dim(" close"),
2335        ]
2336    } else if app.cost.is_some() {
2337        vec![
2338            dim(" "),
2339            key("esc"),
2340            dim(" back   "),
2341            key("q"),
2342            dim(" quit"),
2343        ]
2344    } else if app.wh_picker.is_some() {
2345        vec![
2346            dim(" "),
2347            key("j"),
2348            dim("/"),
2349            key("k"),
2350            dim(" select   "),
2351            key("enter"),
2352            dim(" run preview   "),
2353            key("esc"),
2354            dim(" cancel"),
2355        ]
2356    } else if app.sql.is_some() {
2357        if app.hist_search.is_some() {
2358            vec![
2359                dim(" type to search history   "),
2360                key("^r"),
2361                dim(" older match   "),
2362                key("enter"),
2363                dim(" accept   "),
2364                key("esc"),
2365                dim(" cancel"),
2366            ]
2367        } else {
2368            vec![
2369                dim(" "),
2370                key("enter"),
2371                dim(" run   "),
2372                key("tab"),
2373                dim(" complete   "),
2374                key("↑"),
2375                dim("/"),
2376                key("↓"),
2377                dim(" history   "),
2378                key("^r"),
2379                dim(" search   "),
2380                key("^x"),
2381                dim(" editor   "),
2382                key("pgup"),
2383                dim("/"),
2384                key("pgdn"),
2385                dim(" scroll   "),
2386                key("⇧←→"),
2387                dim(" cols   "),
2388                key("^s"),
2389                dim(" export   "),
2390                key("esc"),
2391                dim(if app.sql.as_ref().is_some_and(|c| c.running) {
2392                    " cancel query"
2393                } else {
2394                    " close"
2395                }),
2396            ]
2397        }
2398    } else if let Some(pv) = &app.preview {
2399        if pv.record {
2400            vec![
2401                dim(" "),
2402                key("j"),
2403                dim("/"),
2404                key("k"),
2405                dim(" fields   "),
2406                key("h"),
2407                dim("/"),
2408                key("l"),
2409                dim(" prev/next row   "),
2410                key("v"),
2411                dim(" grid   "),
2412                key("/"),
2413                dim(" filter cols   "),
2414                key("esc"),
2415                dim(" back"),
2416            ]
2417        } else {
2418            vec![
2419                dim(" "),
2420                key("esc"),
2421                dim(" back   "),
2422                key("j"),
2423                dim("/"),
2424                key("k"),
2425                dim(" rows   "),
2426                key("←"),
2427                dim("/"),
2428                key("→"),
2429                dim(" columns   "),
2430                key("v"),
2431                dim(" row view   "),
2432                key("/"),
2433                dim(" filter cols   "),
2434                key("e"),
2435                dim(" export csv   "),
2436                key("q"),
2437                dim(" quit"),
2438            ]
2439        }
2440    } else if app.picker.is_some() {
2441        vec![
2442            dim(" "),
2443            key("j"),
2444            dim("/"),
2445            key("k"),
2446            dim(" select   "),
2447            key("enter"),
2448            dim(" switch workspace   "),
2449            key("esc"),
2450            dim(" cancel"),
2451        ]
2452    } else if let Some(rv) = &app.run_view {
2453        if rv.show_output {
2454            vec![
2455                dim(" "),
2456                key("esc"),
2457                dim(" back to run   "),
2458                key("j"),
2459                dim("/"),
2460                key("k"),
2461                dim(" scroll   "),
2462                key("q"),
2463                dim(" quit"),
2464            ]
2465        } else {
2466            let mut spans = vec![
2467                dim(" "),
2468                key("esc"),
2469                dim(" back   "),
2470                key("h"),
2471                dim("/"),
2472                key("l"),
2473                dim(" older/newer   "),
2474            ];
2475            if rv.panel == Panel::Jobs {
2476                spans.push(key("o"));
2477                spans.push(dim(" output   "));
2478                spans.push(key("t"));
2479                spans.push(dim(" timeline   "));
2480                spans.push(key("d"));
2481                spans.push(dim(" dag   "));
2482                spans.push(key("r"));
2483                spans.push(dim(" repair   "));
2484            }
2485            spans.extend([
2486                key("s"),
2487                dim(" cancel   "),
2488                key("j"),
2489                dim("/"),
2490                key("k"),
2491                dim(" scroll   "),
2492                key("J"),
2493                dim(" raw   "),
2494                key("q"),
2495                dim(" quit"),
2496            ]);
2497            spans
2498        }
2499    } else if app.detail.is_some() {
2500        let mut spans = vec![dim(" "), key("esc"), dim(" back   ")];
2501        match app.detail.as_ref() {
2502            Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
2503                spans.push(key("enter"));
2504                spans.push(dim(" latest run   "));
2505            }
2506            Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
2507                spans.push(key("enter"));
2508                spans.push(dim(" latest update   "));
2509            }
2510            _ => {}
2511        }
2512        spans.extend([
2513            key("j"),
2514            dim("/"),
2515            key("k"),
2516            dim(" scroll   "),
2517            key("J"),
2518            dim(" raw   "),
2519            key("o"),
2520            dim(" open   "),
2521            key("q"),
2522            dim(" quit"),
2523        ]);
2524        spans
2525    } else {
2526        let mut spans = vec![
2527            dim(" "),
2528            key("tab"),
2529            dim("/"),
2530            key("h"),
2531            dim("/"),
2532            key("l"),
2533            dim(" switch   "),
2534            key("j"),
2535            dim("/"),
2536            key("k"),
2537            dim(" select   "),
2538            key("/"),
2539            dim(if app.active_filter().is_empty() {
2540                " filter   "
2541            } else {
2542                " filter · esc clears   "
2543            }),
2544        ];
2545        // Enter means different things per pane — and nothing on a secret key.
2546        let enter_label = match app.focus {
2547            Panel::Secrets if app.secret_scope.is_some() => None,
2548            Panel::Secrets => Some(" open scope   "),
2549            Panel::Catalog if app.uc_path.len() < 2 => Some(" open   "),
2550            _ => Some(" details   "),
2551        };
2552        if let Some(label) = enter_label {
2553            spans.push(key("enter"));
2554            spans.push(dim(label));
2555        }
2556        // Only show keys that do something in the focused pane.
2557        match app.focus {
2558            Panel::Catalog => {
2559                if !app.uc_path.is_empty() {
2560                    spans.push(key("bksp"));
2561                    spans.push(dim(" up   "));
2562                }
2563                if app.uc_path.len() == 2 {
2564                    spans.push(key("p"));
2565                    spans.push(dim(" preview   "));
2566                    spans.push(key("L"));
2567                    spans.push(dim(" lineage   "));
2568                    spans.push(key(":"));
2569                    spans.push(dim(" query table   "));
2570                }
2571            }
2572            Panel::Dashboards => {}
2573            Panel::Secrets => {
2574                if app.secret_scope.is_some() {
2575                    spans.push(key("bksp"));
2576                    spans.push(dim(" up   "));
2577                }
2578                spans.push(key("a"));
2579                spans.push(dim(if app.secret_scope.is_some() {
2580                    " add secret   "
2581                } else {
2582                    " new scope   "
2583                }));
2584                spans.push(key("x"));
2585                spans.push(dim(" delete   "));
2586            }
2587            _ => {
2588                spans.push(key("s"));
2589                spans.push(dim(" action   "));
2590            }
2591        }
2592        spans.extend([
2593            key("g"),
2594            dim(" access   "),
2595            key("$"),
2596            dim(" cost   "),
2597            key("!"),
2598            dim(" problems   "),
2599            key("u"),
2600            dim(" upcoming   "),
2601        ]);
2602        // At the catalog's table level the ':' hint already reads "query table".
2603        if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
2604            spans.push(key(":"));
2605            spans.push(dim(" sql   "));
2606        }
2607        spans.extend([
2608            key("o"),
2609            dim(" open   "),
2610            key("z"),
2611            dim(if app.zoomed { " unzoom   " } else { " zoom   " }),
2612            key("w"),
2613            dim(" workspace   "),
2614            key("t"),
2615            dim(" theme   "),
2616            key("r"),
2617            dim(" refresh   "),
2618            key("?"),
2619            dim(" help   "),
2620            key("q"),
2621            dim(" quit"),
2622        ]);
2623        spans
2624    };
2625    // A flash message prefixes the key hints instead of hiding them.
2626    if let Some((msg, _)) = &app.flash {
2627        let color = if msg.starts_with('✗') { p.err } else { p.ok };
2628        let mut prefixed = vec![
2629            Span::styled(
2630                format!(" {msg}"),
2631                Style::default().fg(color).add_modifier(Modifier::BOLD),
2632            ),
2633            dim("  ·  "),
2634        ];
2635        prefixed.append(&mut spans);
2636        spans = prefixed;
2637    }
2638    f.render_widget(Paragraph::new(Line::from(spans)), area);
2639}
2640
2641#[allow(clippy::too_many_arguments)]
2642fn draw_panel(
2643    f: &mut Frame,
2644    area: Rect,
2645    panel: Panel,
2646    shape: Option<&Shape>,
2647    focused: bool,
2648    selected: Option<usize>,
2649    fresh: bool,
2650    breadcrumb: &str,
2651    spinner: &str,
2652    filter: &str,
2653    entering: bool,
2654    p: &Palette,
2655) {
2656    let accent = accent(panel, p);
2657    let (border_style, title_style) = if focused {
2658        (
2659            Style::default().fg(accent).add_modifier(Modifier::BOLD),
2660            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2661        )
2662    } else {
2663        (Style::default().fg(p.border), Style::default().fg(p.dim))
2664    };
2665    let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
2666        Some(Shape::List(items)) => Some(
2667            items
2668                .iter()
2669                .filter(|it| crate::shape::item_matches(it, filter))
2670                .collect(),
2671        ),
2672        _ => None,
2673    };
2674    let count = match (&visible, shape) {
2675        // With a filter active, show how many made the cut.
2676        (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
2677            format!(" · {}/{}", v.len(), items.len())
2678        }
2679        (Some(v), _) => format!(" · {}", v.len()),
2680        (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
2681        _ => String::new(),
2682    };
2683    let crumb = if !breadcrumb.is_empty() {
2684        format!(" ▸ {breadcrumb}")
2685    } else {
2686        String::new()
2687    };
2688    // A short reversed flash on the title when fresh data lands.
2689    let title_style = if fresh {
2690        Style::default()
2691            .bg(accent)
2692            .fg(Color::Black)
2693            .add_modifier(Modifier::BOLD)
2694    } else {
2695        title_style
2696    };
2697    let mut title_spans = vec![
2698        Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
2699        Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
2700    ];
2701    if entering || !filter.is_empty() {
2702        title_spans.push(Span::styled(
2703            format!("/{}{} ", filter, if entering { "▏" } else { "" }),
2704            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2705        ));
2706    }
2707    let title = Line::from(title_spans);
2708    let block = Block::default()
2709        .title(title)
2710        .borders(Borders::ALL)
2711        .border_type(if focused {
2712            BorderType::Thick
2713        } else {
2714            BorderType::Rounded
2715        })
2716        .border_style(border_style)
2717        .padding(Padding::horizontal(1));
2718
2719    match shape {
2720        None => {
2721            let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
2722                .style(Style::default().fg(p.warn))
2723                .block(block);
2724            f.render_widget(par, area);
2725        }
2726        Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
2727            let empty = if !items.is_empty() {
2728                format!("∅ nothing matches /{filter}")
2729            } else {
2730                let msg = match panel {
2731                    Panel::Clusters => "no compute running",
2732                    Panel::Jobs => "no jobs defined",
2733                    Panel::Pipelines => "no pipelines",
2734                    Panel::Warehouses => "no warehouses",
2735                    Panel::Dashboards => "no dashboards yet",
2736                    Panel::Catalog => "nothing at this level",
2737                    Panel::Secrets => "no secret scopes",
2738                };
2739                format!("∅ {msg}")
2740            };
2741            let par = Paragraph::new(empty)
2742                .style(Style::default().fg(p.dim))
2743                .block(block);
2744            f.render_widget(par, area);
2745        }
2746        Some(Shape::List(_)) => {
2747            let list_items: Vec<ListItem> = visible
2748                .unwrap_or_default()
2749                .iter()
2750                .map(|item| {
2751                    let color = status_color(&item.status, p);
2752                    let chip = match item.status {
2753                        Status::Stopped | Status::Unknown(_) => Span::styled(
2754                            format!(" {} ", item.status.label()),
2755                            Style::default().fg(color).add_modifier(Modifier::DIM),
2756                        ),
2757                        _ => Span::styled(
2758                            format!(" {} ", item.status.label()),
2759                            Style::default()
2760                                .bg(color)
2761                                .fg(Color::Black)
2762                                .add_modifier(Modifier::BOLD),
2763                        ),
2764                    };
2765                    let mut spans = vec![
2766                        Span::styled("● ", Style::default().fg(color)),
2767                        Span::styled(item.name.as_str(), Style::default().fg(p.text)),
2768                        Span::raw("  "),
2769                        chip,
2770                    ];
2771                    if !item.history.is_empty() {
2772                        spans.push(Span::raw("  "));
2773                        for run in &item.history {
2774                            spans.push(Span::styled(
2775                                history_glyph(run),
2776                                Style::default().fg(status_color(run, p)),
2777                            ));
2778                        }
2779                    }
2780                    if let Some(detail) = &item.detail {
2781                        spans.push(Span::styled(
2782                            format!("  {}", detail),
2783                            Style::default().fg(p.dim),
2784                        ));
2785                    }
2786                    ListItem::new(Line::from(spans))
2787                })
2788                .collect();
2789            let list = List::new(list_items)
2790                .block(block)
2791                .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
2792            match selected {
2793                Some(sel) => {
2794                    let mut state = ListState::default().with_selected(Some(sel));
2795                    f.render_stateful_widget(list, area, &mut state);
2796                }
2797                None => f.render_widget(list, area),
2798            }
2799        }
2800        Some(Shape::Table(data)) => {
2801            let header_cells: Vec<Cell> = data
2802                .headers
2803                .iter()
2804                .map(|h| {
2805                    Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
2806                })
2807                .collect();
2808            let header = Row::new(header_cells).style(Style::default().fg(accent));
2809            let rows: Vec<Row> = data
2810                .rows
2811                .iter()
2812                .map(|r| Row::new(r.iter().map(|c| Cell::from(c.as_str())).collect::<Vec<_>>()))
2813                .collect();
2814            let widths: Vec<Constraint> = data
2815                .headers
2816                .iter()
2817                .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
2818                .collect();
2819            let table = Table::new(rows, widths).header(header).block(block);
2820            f.render_widget(table, area);
2821        }
2822        Some(Shape::Badge(b)) => {
2823            let text = format!("{}: {}", b.label, b.value);
2824            let par = Paragraph::new(text)
2825                .style(Style::default().fg(p.text))
2826                .block(block);
2827            f.render_widget(par, area);
2828        }
2829        Some(Shape::Text(t)) => {
2830            let color = if t.starts_with('✗') { p.err } else { p.text };
2831            let par = Paragraph::new(t.as_str())
2832                .style(Style::default().fg(color))
2833                .wrap(Wrap { trim: false })
2834                .block(block);
2835            f.render_widget(par, area);
2836        }
2837    }
2838}
2839
2840fn status_color(status: &Status, p: &Palette) -> Color {
2841    match status {
2842        Status::Running | Status::Success => p.ok,
2843        Status::Stopped => p.dim,
2844        Status::Pending => p.warn,
2845        Status::Failed => p.err,
2846        Status::Unknown(_) => p.text,
2847    }
2848}
2849
2850fn history_glyph(status: &Status) -> &'static str {
2851    match status {
2852        Status::Success => "✓",
2853        Status::Failed => "✗",
2854        Status::Running => "●",
2855        Status::Pending => "◐",
2856        Status::Stopped => "○",
2857        Status::Unknown(_) => "·",
2858    }
2859}