Skip to main content

databricks_tui/
ui.rs

1use crate::app::{App, Panel, ThemeMode};
2use crate::fetchers;
3use crate::shape::{DetailData, Shape, Status, TableData};
4use ratatui::{
5    layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::{
9        Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph,
10        Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, Wrap,
11    },
12    Frame,
13};
14use std::time::Duration;
15
16struct Palette {
17    text: Color,
18    dim: Color,
19    border: Color,
20    warn: Color,
21    ok: Color,
22    err: Color,
23    key: Color,
24    brand: Color,
25    clusters: Color,
26    jobs: Color,
27    pipelines: Color,
28    warehouses: Color,
29    catalog: Color,
30    /// Header wordmark gradient endpoints.
31    grad_from: (u8, u8, u8),
32    grad_to: (u8, u8, u8),
33}
34
35const fn rgb(hex: u32) -> Color {
36    Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
37}
38
39const fn rgb3(hex: u32) -> (u8, u8, u8) {
40    ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
41}
42
43fn palette(mode: ThemeMode) -> Palette {
44    match mode {
45        // Dark theme sticks to ANSI colors so it follows the terminal's own scheme.
46        ThemeMode::Dark => Palette {
47            text: Color::White,
48            dim: Color::DarkGray,
49            border: Color::DarkGray,
50            warn: Color::Yellow,
51            ok: Color::Green,
52            err: Color::Red,
53            key: Color::Cyan,
54            brand: Color::Red,
55            clusters: Color::Cyan,
56            jobs: Color::Magenta,
57            pipelines: Color::Green,
58            warehouses: Color::Blue,
59            catalog: rgb(0xFF8C42),
60            grad_from: rgb3(0xFF3621),
61            grad_to: rgb3(0xFFA046),
62        },
63        // Light theme uses explicit darker shades that stay readable on a white background.
64        ThemeMode::Light => Palette {
65            text: Color::Black,
66            dim: rgb(0x6B7280),
67            border: rgb(0x9CA3AF),
68            warn: rgb(0xB45309),
69            ok: rgb(0x15803D),
70            err: rgb(0xB91C1C),
71            key: rgb(0x0891B2),
72            brand: rgb(0xDC2626),
73            clusters: rgb(0x0891B2),
74            jobs: rgb(0xA21CAF),
75            pipelines: rgb(0x15803D),
76            warehouses: rgb(0x1D4ED8),
77            catalog: rgb(0xC2410C),
78            grad_from: rgb3(0xB91C1C),
79            grad_to: rgb3(0xC2410C),
80        },
81        ThemeMode::CatppuccinMocha => Palette {
82            text: rgb(0xCDD6F4),
83            dim: rgb(0x6C7086),
84            border: rgb(0x585B70),
85            warn: rgb(0xF9E2AF),
86            ok: rgb(0xA6E3A1),
87            err: rgb(0xF38BA8),
88            key: rgb(0x89DCEB),
89            brand: rgb(0xF38BA8),
90            clusters: rgb(0x89DCEB),
91            jobs: rgb(0xCBA6F7),
92            pipelines: rgb(0xA6E3A1),
93            warehouses: rgb(0x89B4FA),
94            catalog: rgb(0xFAB387),
95            grad_from: rgb3(0xF38BA8),
96            grad_to: rgb3(0xFAB387),
97        },
98        ThemeMode::CatppuccinMacchiato => Palette {
99            text: rgb(0xCAD3F5),
100            dim: rgb(0x6E738D),
101            border: rgb(0x5B6078),
102            warn: rgb(0xEED49F),
103            ok: rgb(0xA6DA95),
104            err: rgb(0xED8796),
105            key: rgb(0x91D7E3),
106            brand: rgb(0xED8796),
107            clusters: rgb(0x91D7E3),
108            jobs: rgb(0xC6A0F6),
109            pipelines: rgb(0xA6DA95),
110            warehouses: rgb(0x8AADF4),
111            catalog: rgb(0xF5A97F),
112            grad_from: rgb3(0xED8796),
113            grad_to: rgb3(0xF5A97F),
114        },
115        ThemeMode::CatppuccinFrappe => Palette {
116            text: rgb(0xC6D0F5),
117            dim: rgb(0x737994),
118            border: rgb(0x626880),
119            warn: rgb(0xE5C890),
120            ok: rgb(0xA6D189),
121            err: rgb(0xE78284),
122            key: rgb(0x99D1DB),
123            brand: rgb(0xE78284),
124            clusters: rgb(0x99D1DB),
125            jobs: rgb(0xCA9EE6),
126            pipelines: rgb(0xA6D189),
127            warehouses: rgb(0x8CAAEE),
128            catalog: rgb(0xEF9F76),
129            grad_from: rgb3(0xE78284),
130            grad_to: rgb3(0xEF9F76),
131        },
132        ThemeMode::CatppuccinLatte => Palette {
133            text: rgb(0x4C4F69),
134            dim: rgb(0x8C8FA1),
135            border: rgb(0xACB0BE),
136            warn: rgb(0xDF8E1D),
137            ok: rgb(0x40A02B),
138            err: rgb(0xD20F39),
139            key: rgb(0x04A5E5),
140            brand: rgb(0xD20F39),
141            clusters: rgb(0x04A5E5),
142            jobs: rgb(0x8839EF),
143            pipelines: rgb(0x40A02B),
144            warehouses: rgb(0x1E66F5),
145            catalog: rgb(0xFE640B),
146            grad_from: rgb3(0xD20F39),
147            grad_to: rgb3(0xFE640B),
148        },
149        ThemeMode::GruvboxDark => Palette {
150            text: rgb(0xEBDBB2),
151            dim: rgb(0x928374),
152            border: rgb(0x665C54),
153            warn: rgb(0xFABD2F),
154            ok: rgb(0xB8BB26),
155            err: rgb(0xFB4934),
156            key: rgb(0x8EC07C),
157            brand: rgb(0xFB4934),
158            clusters: rgb(0x8EC07C),
159            jobs: rgb(0xD3869B),
160            pipelines: rgb(0xB8BB26),
161            warehouses: rgb(0x83A598),
162            catalog: rgb(0xFE8019),
163            grad_from: rgb3(0xFB4934),
164            grad_to: rgb3(0xFE8019),
165        },
166        ThemeMode::GruvboxLight => Palette {
167            text: rgb(0x3C3836),
168            dim: rgb(0x928374),
169            border: rgb(0xBDAE93),
170            warn: rgb(0xB57614),
171            ok: rgb(0x79740E),
172            err: rgb(0x9D0006),
173            key: rgb(0x427B58),
174            brand: rgb(0x9D0006),
175            clusters: rgb(0x427B58),
176            jobs: rgb(0x8F3F71),
177            pipelines: rgb(0x79740E),
178            warehouses: rgb(0x076678),
179            catalog: rgb(0xAF3A03),
180            grad_from: rgb3(0x9D0006),
181            grad_to: rgb3(0xAF3A03),
182        },
183        ThemeMode::Dracula => Palette {
184            text: rgb(0xF8F8F2),
185            dim: rgb(0x6272A4),
186            border: rgb(0x44475A),
187            warn: rgb(0xF1FA8C),
188            ok: rgb(0x50FA7B),
189            err: rgb(0xFF5555),
190            key: rgb(0x8BE9FD),
191            brand: rgb(0xFF5555),
192            clusters: rgb(0x8BE9FD),
193            jobs: rgb(0xFF79C6),
194            pipelines: rgb(0x50FA7B),
195            warehouses: rgb(0xBD93F9),
196            catalog: rgb(0xFFB86C),
197            grad_from: rgb3(0xFF5555),
198            grad_to: rgb3(0xFFB86C),
199        },
200        ThemeMode::Nord => Palette {
201            text: rgb(0xD8DEE9),
202            dim: rgb(0x4C566A),
203            border: rgb(0x434C5E),
204            warn: rgb(0xEBCB8B),
205            ok: rgb(0xA3BE8C),
206            err: rgb(0xBF616A),
207            key: rgb(0x88C0D0),
208            brand: rgb(0xBF616A),
209            clusters: rgb(0x88C0D0),
210            jobs: rgb(0xB48EAD),
211            pipelines: rgb(0xA3BE8C),
212            warehouses: rgb(0x81A1C1),
213            catalog: rgb(0xD08770),
214            grad_from: rgb3(0xBF616A),
215            grad_to: rgb3(0xD08770),
216        },
217        ThemeMode::TokyoNight => Palette {
218            text: rgb(0xC0CAF5),
219            dim: rgb(0x565F89),
220            border: rgb(0x3B4261),
221            warn: rgb(0xE0AF68),
222            ok: rgb(0x9ECE6A),
223            err: rgb(0xF7768E),
224            key: rgb(0x7DCFFF),
225            brand: rgb(0xF7768E),
226            clusters: rgb(0x7DCFFF),
227            jobs: rgb(0xBB9AF7),
228            pipelines: rgb(0x9ECE6A),
229            warehouses: rgb(0x7AA2F7),
230            catalog: rgb(0xFF9E64),
231            grad_from: rgb3(0xF7768E),
232            grad_to: rgb3(0xFF9E64),
233        },
234        ThemeMode::RosePine => Palette {
235            text: rgb(0xE0DEF4),
236            dim: rgb(0x6E6A86),
237            border: rgb(0x403D52),
238            warn: rgb(0xF6C177),
239            ok: rgb(0x31748F),
240            err: rgb(0xEB6F92),
241            key: rgb(0x9CCFD8),
242            brand: rgb(0xEB6F92),
243            clusters: rgb(0x9CCFD8),
244            jobs: rgb(0xC4A7E7),
245            pipelines: rgb(0x31748F),
246            warehouses: rgb(0x3E8FB0),
247            catalog: rgb(0xEBBCBA),
248            grad_from: rgb3(0xEB6F92),
249            grad_to: rgb3(0xEBBCBA),
250        },
251        ThemeMode::Everforest => Palette {
252            text: rgb(0xD3C6AA),
253            dim: rgb(0x859289),
254            border: rgb(0x475258),
255            warn: rgb(0xDBBC7F),
256            ok: rgb(0xA7C080),
257            err: rgb(0xE67E80),
258            key: rgb(0x83C092),
259            brand: rgb(0xE67E80),
260            clusters: rgb(0x83C092),
261            jobs: rgb(0xD699B6),
262            pipelines: rgb(0xA7C080),
263            warehouses: rgb(0x7FBBB3),
264            catalog: rgb(0xE69875),
265            grad_from: rgb3(0xE67E80),
266            grad_to: rgb3(0xE69875),
267        },
268        ThemeMode::Kanagawa => Palette {
269            text: rgb(0xDCD7BA),
270            dim: rgb(0x727169),
271            border: rgb(0x54546D),
272            warn: rgb(0xE6C384),
273            ok: rgb(0x98BB6C),
274            err: rgb(0xC34043),
275            key: rgb(0x7FB4CA),
276            brand: rgb(0xC34043),
277            clusters: rgb(0x7FB4CA),
278            jobs: rgb(0x957FB8),
279            pipelines: rgb(0x98BB6C),
280            warehouses: rgb(0x7E9CD8),
281            catalog: rgb(0xFFA066),
282            grad_from: rgb3(0xC34043),
283            grad_to: rgb3(0xFFA066),
284        },
285        ThemeMode::SolarizedDark => Palette {
286            text: rgb(0x93A1A1),
287            dim: rgb(0x586E75),
288            border: rgb(0x073642),
289            warn: rgb(0xB58900),
290            ok: rgb(0x859900),
291            err: rgb(0xDC322F),
292            key: rgb(0x2AA198),
293            brand: rgb(0xDC322F),
294            clusters: rgb(0x2AA198),
295            jobs: rgb(0xD33682),
296            pipelines: rgb(0x859900),
297            warehouses: rgb(0x268BD2),
298            catalog: rgb(0xCB4B16),
299            grad_from: rgb3(0xDC322F),
300            grad_to: rgb3(0xCB4B16),
301        },
302        ThemeMode::OneDark => Palette {
303            text: rgb(0xABB2BF),
304            dim: rgb(0x5C6370),
305            border: rgb(0x3E4451),
306            warn: rgb(0xE5C07B),
307            ok: rgb(0x98C379),
308            err: rgb(0xE06C75),
309            key: rgb(0x56B6C2),
310            brand: rgb(0xE06C75),
311            clusters: rgb(0x56B6C2),
312            jobs: rgb(0xC678DD),
313            pipelines: rgb(0x98C379),
314            warehouses: rgb(0x61AFEF),
315            catalog: rgb(0xD19A66),
316            grad_from: rgb3(0xE06C75),
317            grad_to: rgb3(0xD19A66),
318        },
319    }
320}
321
322fn accent(panel: Panel, p: &Palette) -> Color {
323    match panel {
324        Panel::Clusters => p.clusters,
325        Panel::Jobs => p.jobs,
326        Panel::Pipelines => p.pipelines,
327        Panel::Warehouses => p.warehouses,
328        Panel::Dashboards => p.key,
329        Panel::Catalog => p.catalog,
330        Panel::Secrets => p.warn,
331    }
332}
333
334fn pane_breadcrumb(app: &App, panel: Panel) -> String {
335    match panel {
336        Panel::Catalog => app.uc_path.join("."),
337        Panel::Secrets => app.secret_scope.clone().unwrap_or_default(),
338        _ => String::new(),
339    }
340}
341
342pub fn draw(f: &mut Frame, app: &App) {
343    let p = palette(app.theme);
344
345    if app.splash_active() {
346        draw_splash(f, f.area(), app, &p);
347        return;
348    }
349
350    let root = Layout::default()
351        .direction(Direction::Vertical)
352        .constraints([
353            Constraint::Length(3),
354            Constraint::Min(0),
355            Constraint::Length(1),
356        ])
357        .split(f.area());
358
359    draw_header(f, root[0], app, &p);
360    draw_footer(f, root[2], app, &p);
361
362    if app.sql.is_some() {
363        draw_sql(f, root[1], app, &p);
364        if app.sql_complete.is_some() {
365            draw_sql_complete(f, root[1], app, &p);
366        }
367        // Running a query may pop the warehouse picker over the console.
368        if app.wh_picker.is_some() {
369            draw_wh_picker(f, root[1], app, &p);
370        }
371        return;
372    }
373
374    if app.cost.is_some() {
375        draw_cost(f, root[1], app, &p);
376        return;
377    }
378
379    if app.item_cost.is_some() {
380        draw_item_cost(f, root[1], app, &p);
381        return;
382    }
383
384    if app.preview.is_some() {
385        draw_preview(f, root[1], app, &p);
386        return;
387    }
388
389    if app.run_view.is_some() {
390        draw_run(f, root[1], app, &p);
391        return;
392    }
393
394    if app.detail.is_some() {
395        draw_detail(f, root[1], app, &p);
396        return;
397    }
398
399    if app.zoomed {
400        let idx = Panel::ALL
401            .iter()
402            .position(|pn| pn == &app.focus)
403            .unwrap_or(0);
404        draw_panel(
405            f,
406            root[1],
407            app.focus,
408            app.shapes[idx].as_ref(),
409            true,
410            Some(app.selection(idx)),
411            false,
412            &pane_breadcrumb(app, app.focus),
413            app.spinner(),
414            &app.filters[idx],
415            app.filter_entry,
416            &app.pane_fav_set(idx),
417            app.fav_filter_active(idx),
418            &p,
419        );
420        // Overlays must still render on top of the zoomed pane.
421        if app.picker.is_some() {
422            draw_picker(f, root[1], app, &p);
423        }
424        if app.wh_picker.is_some() {
425            draw_wh_picker(f, root[1], app, &p);
426        }
427        if app.problems.is_some() {
428            draw_problems(f, root[1], app, &p);
429        }
430        if app.upcoming.is_some() {
431            draw_upcoming(f, root[1], app, &p);
432        }
433        if app.jump.is_some() {
434            draw_jump(f, root[1], app, &p);
435        }
436        if app.pane_cfg.is_some() {
437            draw_pane_cfg(f, root[1], app, &p);
438        }
439        if app.secret_form.is_some() {
440            draw_secret_form(f, root[1], app, &p);
441        }
442        if app.param_form.is_some() {
443            draw_param_form(f, root[1], app, &p);
444        }
445        if app.help {
446            draw_help(f, root[1], app, &p);
447        }
448        return;
449    }
450
451    // The grid adapts to however many panes are visible, in the user's
452    // order: left column gets the first half (rounded up), right the rest.
453    let visible = app.visible_panes();
454    if visible.is_empty() {
455        let par = Paragraph::new("all panes hidden — press H to bring them back")
456            .style(Style::default().fg(p.dim))
457            .alignment(Alignment::Center);
458        f.render_widget(par, root[1]);
459    } else {
460        let rows_of = |n: usize| -> Vec<Constraint> {
461            (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
462        };
463        let left_count = visible.len().div_ceil(2);
464        let right_count = visible.len() - left_count;
465        let mut areas: Vec<Rect> = Vec::new();
466        if right_count == 0 {
467            areas.extend(
468                Layout::default()
469                    .direction(Direction::Vertical)
470                    .constraints(rows_of(left_count))
471                    .split(root[1])
472                    .iter(),
473            );
474        } else {
475            let body = Layout::default()
476                .direction(Direction::Horizontal)
477                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
478                .split(root[1]);
479            areas.extend(
480                Layout::default()
481                    .direction(Direction::Vertical)
482                    .constraints(rows_of(left_count))
483                    .split(body[0])
484                    .iter(),
485            );
486            areas.extend(
487                Layout::default()
488                    .direction(Direction::Vertical)
489                    .constraints(rows_of(right_count))
490                    .split(body[1])
491                    .iter(),
492            );
493        }
494
495        for (slot, &i) in visible.iter().enumerate() {
496            let panel = Panel::ALL[i];
497            let focused = app.focus == panel;
498            let shape = app.shapes[i].as_ref();
499            let selected = focused.then(|| app.selection(i));
500            let fresh = app.updated_at[i]
501                .map(|t| t.elapsed() < Duration::from_millis(1200))
502                .unwrap_or(false);
503            draw_panel(
504                f,
505                areas[slot],
506                panel,
507                shape,
508                focused,
509                selected,
510                fresh,
511                &pane_breadcrumb(app, panel),
512                app.spinner(),
513                &app.filters[i],
514                app.filter_entry && focused,
515                &app.pane_fav_set(i),
516                app.fav_filter_active(i),
517                &p,
518            );
519        }
520    }
521
522    if app.picker.is_some() {
523        draw_picker(f, root[1], app, &p);
524    }
525    if app.wh_picker.is_some() {
526        draw_wh_picker(f, root[1], app, &p);
527    }
528    if app.problems.is_some() {
529        draw_problems(f, root[1], app, &p);
530    }
531    if app.upcoming.is_some() {
532        draw_upcoming(f, root[1], app, &p);
533    }
534    if app.jump.is_some() {
535        draw_jump(f, root[1], app, &p);
536    }
537    if app.pane_cfg.is_some() {
538        draw_pane_cfg(f, root[1], app, &p);
539    }
540    if app.secret_form.is_some() {
541        draw_secret_form(f, root[1], app, &p);
542    }
543    if app.param_form.is_some() {
544        draw_param_form(f, root[1], app, &p);
545    }
546    if app.help {
547        draw_help(f, root[1], app, &p);
548    }
549}
550
551fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
552    let Some(form) = &app.secret_form else {
553        return;
554    };
555    let width = 60.min(area.width.saturating_sub(4));
556    let height = 6.min(area.height);
557    let popup = Rect {
558        x: area.x + (area.width.saturating_sub(width)) / 2,
559        y: area.y + (area.height.saturating_sub(height)) / 2,
560        width,
561        height,
562    };
563    f.render_widget(Clear, popup);
564    let title = match &form.scope {
565        None => "New secret scope ".to_string(),
566        Some(s) => format!("New secret in {s} "),
567    };
568    let block = Block::default()
569        .title(Line::from(vec![
570            Span::styled(" ◈ ", Style::default().fg(p.warn)),
571            Span::styled(
572                title,
573                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
574            ),
575        ]))
576        .borders(Borders::ALL)
577        .border_type(BorderType::Thick)
578        .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
579        .padding(Padding::new(1, 1, 1, 0));
580    let label0 = if form.scope.is_none() {
581        "name "
582    } else {
583        "key  "
584    };
585    let mut lines = vec![Line::from(vec![
586        Span::styled(label0, Style::default().fg(p.dim)),
587        Span::styled(form.key.as_str(), Style::default().fg(p.text)),
588        if form.stage == 0 {
589            Span::styled("▏", Style::default().fg(p.warn))
590        } else {
591            Span::raw("")
592        },
593    ])];
594    if form.scope.is_some() {
595        // The value is never echoed — bullets only.
596        lines.push(Line::from(vec![
597            Span::styled("value ", Style::default().fg(p.dim)),
598            Span::styled(
599                "•".repeat(form.value.chars().count()),
600                Style::default().fg(p.text),
601            ),
602            if form.stage == 1 {
603                Span::styled("▏", Style::default().fg(p.warn))
604            } else {
605                Span::raw("")
606            },
607        ]));
608    }
609    f.render_widget(Paragraph::new(lines).block(block), popup);
610}
611
612fn draw_param_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
613    let Some(form) = &app.param_form else {
614        return;
615    };
616    let width = 70.min(area.width.saturating_sub(4));
617    let height = 6.min(area.height);
618    let popup = Rect {
619        x: area.x + (area.width.saturating_sub(width)) / 2,
620        y: area.y + (area.height.saturating_sub(height)) / 2,
621        width,
622        height,
623    };
624    f.render_widget(Clear, popup);
625    let block = Block::default()
626        .title(Line::from(vec![
627            Span::styled(" ▸ ", Style::default().fg(p.warn)),
628            Span::styled(
629                format!("Run “{}” with parameters ", form.job),
630                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
631            ),
632        ]))
633        .borders(Borders::ALL)
634        .border_type(BorderType::Thick)
635        .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
636        .padding(Padding::new(1, 1, 1, 0));
637    let lines = if form.loading {
638        vec![Line::from(Span::styled(
639            format!("{} loading current parameters…", app.spinner()),
640            Style::default().fg(p.warn),
641        ))]
642    } else {
643        // Caret rendered by splitting the input at the cursor.
644        let at = form
645            .input
646            .char_indices()
647            .nth(form.cursor)
648            .map(|(i, _)| i)
649            .unwrap_or(form.input.len());
650        let kind = match form.kind {
651            crate::fetchers::jobs::ParamKind::Job => "job parameters",
652            crate::fetchers::jobs::ParamKind::Notebook => "notebook parameters",
653        };
654        vec![
655            Line::from(vec![
656                Span::styled("❯ ", Style::default().fg(p.warn)),
657                Span::styled(&form.input[..at], Style::default().fg(p.text)),
658                Span::styled("▏", Style::default().fg(p.warn)),
659                Span::styled(&form.input[at..], Style::default().fg(p.text)),
660            ]),
661            Line::default(),
662            Line::from(Span::styled(
663                format!("key=value, comma-separated · sent as {kind} · empty runs as-is"),
664                Style::default().fg(p.dim),
665            )),
666        ]
667    };
668    f.render_widget(Paragraph::new(lines).block(block), popup);
669}
670
671fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
672    let Some(selected) = app.pane_cfg else {
673        return;
674    };
675    let width = 52.min(area.width.saturating_sub(4));
676    let height = 11.min(area.height);
677    let popup = Rect {
678        x: area.x + (area.width.saturating_sub(width)) / 2,
679        y: area.y + (area.height.saturating_sub(height)) / 2,
680        width,
681        height,
682    };
683    f.render_widget(Clear, popup);
684    let block = Block::default()
685        .title(Line::from(vec![
686            Span::styled(" ▦ ", Style::default().fg(p.key)),
687            Span::styled(
688                "Panes ",
689                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
690            ),
691        ]))
692        .borders(Borders::ALL)
693        .border_type(BorderType::Thick)
694        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
695        .padding(Padding::new(1, 1, 1, 0));
696    let items: Vec<ListItem> = app
697        .pane_order
698        .iter()
699        .map(|&i| {
700            let panel = Panel::ALL[i];
701            let (mark, style) = if app.hidden[i] {
702                ("○ ", Style::default().fg(p.dim))
703            } else {
704                ("● ", Style::default().fg(p.ok))
705            };
706            ListItem::new(Line::from(vec![
707                Span::styled(mark, style),
708                Span::styled(
709                    format!("{} ", panel.icon()),
710                    Style::default().fg(accent(panel, p)),
711                ),
712                Span::styled(
713                    panel.title(),
714                    if app.hidden[i] {
715                        Style::default().fg(p.dim)
716                    } else {
717                        Style::default().fg(p.text)
718                    },
719                ),
720            ]))
721        })
722        .collect();
723    let list = List::new(items)
724        .block(block)
725        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
726    let mut state = ListState::default().with_selected(Some(selected));
727    f.render_stateful_widget(list, popup, &mut state);
728}
729
730fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
731    let width = 74.min(area.width.saturating_sub(4));
732    let height = area.height.saturating_sub(2);
733    let popup = Rect {
734        x: area.x + (area.width.saturating_sub(width)) / 2,
735        y: area.y + 1,
736        width,
737        height,
738    };
739    f.render_widget(Clear, popup);
740    let block = Block::default()
741        .title(Line::from(vec![
742            Span::styled(" ? ", Style::default().fg(p.key)),
743            Span::styled(
744                "Keys ",
745                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
746            ),
747        ]))
748        .borders(Borders::ALL)
749        .border_type(BorderType::Thick)
750        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
751        .padding(Padding::new(1, 1, 1, 0));
752
753    let sections: &[(&str, &[(&str, &str)])] = &[
754        (
755            "Global",
756            &[
757                ("tab / l, shift+tab / h", "focus next / previous pane"),
758                ("j / k", "select item"),
759                ("enter", "open details / drill down"),
760                ("/", "filter the focused pane (esc clears)"),
761                ("ctrl+p", "command palette: fuzzy-jump anywhere"),
762                ("!", "problems: failures in every workspace, enter jumps"),
763                ("u", "upcoming runs: what fires next, soonest first"),
764                (":", "SQL console (prefilled on a catalog table)"),
765                ("$", "cost view: DBUs, dollars, top spenders"),
766                ("H", "arrange panes: hide and reorder"),
767                ("z", "zoom the focused pane"),
768                ("w", "switch workspace profile"),
769                ("t", "cycle color theme"),
770                ("r", "refresh now"),
771                ("?", "this help"),
772                ("q / ctrl+c", "quit"),
773            ],
774        ),
775        (
776            "Focused pane",
777            &[
778                ("s", "start / stop / run the selected item"),
779                ("c", "jobs/pipelines: spend over week, month, quarter, year"),
780                ("p", "jobs: on the run confirm, edit parameters first"),
781                ("S", "jobs: pause / resume the schedule or trigger"),
782                ("f", "pin / unpin the selected item as a favorite"),
783                ("F", "show only favorites in the focused pane"),
784                ("g", "access: grants and permissions"),
785                ("o", "open in the workspace web UI"),
786            ],
787        ),
788        (
789            "Unity Catalog pane",
790            &[
791                ("enter / backspace", "drill down / up (into volumes too)"),
792                ("p / P", "preview table rows (P picks the warehouse)"),
793                ("L", "lineage: upstream and downstream tables"),
794                (":", "query the selected table"),
795                ("enter on a file", "peek at its first 200 lines"),
796            ],
797        ),
798        (
799            "Details & runs",
800            &[
801                ("enter", "job/pipeline detail → latest run/update"),
802                ("h / l", "older / newer run"),
803                ("o", "full task output — keeps tailing while live"),
804                ("t", "timeline: per-task Gantt of the run"),
805                ("d", "dag: task dependency tree of the run"),
806                ("g", "grid: task states across recent runs, with trends"),
807                ("r", "repair a failed run — reruns only failed tasks"),
808                ("W", "watch the run — bell + flash when it finishes"),
809                ("s", "cancel the shown run"),
810                ("j / k, J", "scroll · toggle raw JSON"),
811            ],
812        ),
813        (
814            "Table previews",
815            &[
816                ("j / k", "scroll rows"),
817                ("← / →", "page columns (wide tables)"),
818                ("/", "filter columns by name"),
819                ("v / enter", "record view: one row, fields stacked"),
820                ("e", "export rows to CSV"),
821            ],
822        ),
823        (
824            "Secret scopes pane",
825            &[
826                ("enter / backspace", "open a scope / back to scopes"),
827                ("a", "create a scope / add a secret (value masked)"),
828                ("x", "delete the selected scope or secret"),
829                ("g", "scope ACLs"),
830            ],
831        ),
832        (
833            "SQL console",
834            &[
835                ("enter", "run the statement"),
836                ("tab", "complete catalog/schema/table/column names"),
837                ("↑ / ↓", "history · walk fields one by one in record view"),
838                ("ctrl+r", "incremental history search"),
839                ("ctrl+x", "compose in $EDITOR"),
840                ("ctrl+s", "export results to CSV"),
841                ("ctrl+v / f2", "record view: one row, fields stacked"),
842                ("pgup / pgdn", "scroll results · page fields in record view"),
843                ("shift+← / →", "page result columns · rows in record view"),
844                (
845                    "esc",
846                    "cancel a running query, leave record view, else close",
847                ),
848            ],
849        ),
850    ];
851    let mut lines: Vec<Line> = Vec::new();
852    for (title, keys) in sections {
853        lines.push(Line::from(Span::styled(
854            *title,
855            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
856        )));
857        for (k, desc) in *keys {
858            lines.push(Line::from(vec![
859                Span::styled(format!("  {k:<24}"), Style::default().fg(p.warn)),
860                Span::styled(*desc, Style::default().fg(p.text)),
861            ]));
862        }
863        lines.push(Line::default());
864    }
865    let total = lines.len();
866    let par = Paragraph::new(lines)
867        .scroll((app.help_scroll, 0))
868        .block(block);
869    f.render_widget(par, popup);
870    // Viewport loses the borders plus the block's one line of top padding.
871    scrollbar(
872        f,
873        popup,
874        total,
875        popup.height.saturating_sub(3) as usize,
876        app.help_scroll as usize,
877        p,
878    );
879}
880
881fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
882    let Some(jump) = &app.jump else {
883        return;
884    };
885    let matches = app.jump_matches();
886    let width = 70.min(area.width.saturating_sub(4));
887    let height = (matches.len() as u16 + 5).min(area.height);
888    let popup = Rect {
889        x: area.x + (area.width.saturating_sub(width)) / 2,
890        y: area.y + 2.min(area.height.saturating_sub(height)),
891        width,
892        height,
893    };
894    f.render_widget(Clear, popup);
895    let block = Block::default()
896        .title(Line::from(vec![
897            Span::styled(" ⌕ ", Style::default().fg(p.key)),
898            Span::styled(
899                "Jump to ",
900                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
901            ),
902        ]))
903        .borders(Borders::ALL)
904        .border_type(BorderType::Thick)
905        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
906        .padding(Padding::horizontal(1));
907    let inner = block.inner(popup);
908    f.render_widget(block, popup);
909
910    let parts = Layout::default()
911        .direction(Direction::Vertical)
912        .constraints([Constraint::Length(2), Constraint::Min(0)])
913        .split(inner);
914    f.render_widget(
915        Paragraph::new(Line::from(vec![
916            Span::styled("❯ ", Style::default().fg(p.key)),
917            Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
918            Span::styled("▏", Style::default().fg(p.warn)),
919        ])),
920        parts[0],
921    );
922
923    if matches.is_empty() {
924        f.render_widget(
925            Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
926            parts[1],
927        );
928        return;
929    }
930    let items: Vec<ListItem> = matches
931        .iter()
932        .map(|(panel_idx, name, label)| {
933            let panel = Panel::ALL[*panel_idx];
934            ListItem::new(Line::from(vec![
935                Span::styled(
936                    format!("{} ", panel.icon()),
937                    Style::default().fg(accent(panel, p)),
938                ),
939                Span::styled(format!("{name}  "), Style::default().fg(p.text)),
940                Span::styled(label.clone(), Style::default().fg(p.dim)),
941            ]))
942        })
943        .collect();
944    let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
945    let mut state = ListState::default().with_selected(Some(jump.index));
946    f.render_stateful_widget(list, parts[1], &mut state);
947}
948
949fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
950    let Some(pr) = &app.problems else {
951        return;
952    };
953    let truncate = |s: &str, max: usize| -> String {
954        if s.chars().count() <= max {
955            s.to_string()
956        } else {
957            let cut: String = s.chars().take(max.saturating_sub(1)).collect();
958            format!("{cut}…")
959        }
960    };
961    // Names get a bounded column (cross-workspace rows carry a
962    // "profile ▸ " prefix); notes (which can be whole CLI error lines)
963    // take whatever room is left and are truncated to fit.
964    let prefix_of = |problem: &crate::app::Problem| -> String {
965        problem
966            .profile
967            .as_ref()
968            .map(|pr| format!("{pr} ▸ "))
969            .unwrap_or_default()
970    };
971    let name_w = pr
972        .items
973        .iter()
974        .map(|i| i.name.chars().count() + prefix_of(i).chars().count())
975        .max()
976        .unwrap_or(10)
977        .clamp(10, 40);
978    let note_w = pr
979        .items
980        .iter()
981        .map(|i| i.note.chars().count())
982        .max()
983        .unwrap_or(0);
984    // dot(2) + name + gap(2) + icon+title(20) + note + padding/borders(8)
985    let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
986    let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
987    // Room left for the note on each row inside borders and padding.
988    let note_space = (width as usize).saturating_sub(name_w + 30);
989    let popup = Rect {
990        x: area.x + (area.width.saturating_sub(width)) / 2,
991        y: area.y + (area.height.saturating_sub(height)) / 2,
992        width,
993        height,
994    };
995    f.render_widget(Clear, popup);
996    let mut title_spans = vec![
997        Span::styled(" ✗ ", Style::default().fg(p.err)),
998        Span::styled(
999            format!("Problems · {} ", pr.items.len()),
1000            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1001        ),
1002    ];
1003    if pr.scanning {
1004        title_spans.push(Span::styled(
1005            format!("· {} scanning workspaces ", app.spinner()),
1006            Style::default().fg(p.warn),
1007        ));
1008    }
1009    let block = Block::default()
1010        .title(Line::from(title_spans))
1011        .borders(Borders::ALL)
1012        .border_type(BorderType::Rounded)
1013        .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
1014        .padding(Padding::new(1, 1, 1, 1));
1015
1016    if pr.items.is_empty() {
1017        let (text, color) = if pr.scanning {
1018            (
1019                format!(
1020                    "✓ nothing failing here — {} scanning other workspaces…",
1021                    app.spinner()
1022                ),
1023                p.warn,
1024            )
1025        } else {
1026            ("✓ all clear — nothing failing right now".to_string(), p.ok)
1027        };
1028        let par = Paragraph::new(text)
1029            .style(Style::default().fg(color))
1030            .block(block);
1031        f.render_widget(par, popup);
1032        return;
1033    }
1034
1035    let items: Vec<ListItem> = pr
1036        .items
1037        .iter()
1038        .map(|problem| {
1039            let prefix = prefix_of(problem);
1040            let plen = prefix.chars().count().min(name_w.saturating_sub(4));
1041            let mut spans = vec![
1042                Span::styled("✗ ", Style::default().fg(p.err)),
1043                Span::styled(truncate(&prefix, plen), Style::default().fg(p.dim)),
1044                Span::styled(
1045                    format!(
1046                        "{:<width$}",
1047                        truncate(&problem.name, name_w - plen),
1048                        width = name_w + 2 - plen
1049                    ),
1050                    Style::default().fg(p.text),
1051                ),
1052            ];
1053            spans.push(match problem.panel {
1054                Some(i) => {
1055                    let panel = Panel::ALL[i];
1056                    Span::styled(
1057                        format!("{} {:<18}", panel.icon(), panel.title()),
1058                        Style::default().fg(accent(panel, p)),
1059                    )
1060                }
1061                None => Span::styled(format!("⌂ {:<18}", "workspace"), Style::default().fg(p.dim)),
1062            });
1063            spans.push(Span::styled(
1064                truncate(&problem.note, note_space),
1065                Style::default().fg(p.dim),
1066            ));
1067            ListItem::new(Line::from(spans))
1068        })
1069        .collect();
1070    let list = List::new(items)
1071        .block(block)
1072        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1073    let mut state = ListState::default().with_selected(Some(pr.index));
1074    f.render_stateful_widget(list, popup, &mut state);
1075}
1076
1077/// Overlay listing every job that will run again on its own, soonest
1078/// first: countdown, job name, and the schedule that drives it.
1079fn draw_upcoming(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1080    let Some(u) = &app.upcoming else {
1081        return;
1082    };
1083    let label_w = u
1084        .items
1085        .iter()
1086        .map(|i| i.next.label.chars().count())
1087        .max()
1088        .unwrap_or(8)
1089        .clamp(8, 18);
1090    let name_w = u
1091        .items
1092        .iter()
1093        .map(|i| i.name.chars().count())
1094        .max()
1095        .unwrap_or(10)
1096        .clamp(10, 40);
1097    let desc_w = u
1098        .items
1099        .iter()
1100        .map(|i| i.next.desc.chars().count())
1101        .max()
1102        .unwrap_or(0);
1103    let width = ((label_w + name_w + desc_w + 12) as u16).min(area.width.saturating_sub(4));
1104    let height = (u.items.len().max(1) as u16 + 4).min(area.height);
1105    let desc_space = (width as usize).saturating_sub(label_w + name_w + 10);
1106    let popup = Rect {
1107        x: area.x + (area.width.saturating_sub(width)) / 2,
1108        y: area.y + (area.height.saturating_sub(height)) / 2,
1109        width,
1110        height,
1111    };
1112    f.render_widget(Clear, popup);
1113    let block = Block::default()
1114        .title(Line::from(vec![
1115            Span::styled(" ⏱ ", Style::default().fg(p.key)),
1116            Span::styled(
1117                format!("Upcoming runs · {} ", u.items.len()),
1118                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1119            ),
1120        ]))
1121        .borders(Borders::ALL)
1122        .border_type(BorderType::Rounded)
1123        .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
1124        .padding(Padding::new(1, 1, 1, 1));
1125
1126    if u.loading {
1127        let par = Paragraph::new(format!("{} reading job schedules…", app.spinner()))
1128            .style(Style::default().fg(p.warn))
1129            .block(block);
1130        f.render_widget(par, popup);
1131        return;
1132    }
1133    if u.items.is_empty() {
1134        let par = Paragraph::new("no scheduled or triggered jobs — everything runs on demand")
1135            .style(Style::default().fg(p.dim))
1136            .block(block);
1137        f.render_widget(par, popup);
1138        return;
1139    }
1140
1141    let truncate = |s: &str, max: usize| -> String {
1142        if s.chars().count() <= max {
1143            s.to_string()
1144        } else {
1145            let cut: String = s.chars().take(max.saturating_sub(1)).collect();
1146            format!("{cut}…")
1147        }
1148    };
1149    let items: Vec<ListItem> = u
1150        .items
1151        .iter()
1152        .map(|it| {
1153            let when_color = if it.next.paused {
1154                p.dim
1155            } else if it.next.at_ms.is_some() {
1156                p.ok
1157            } else {
1158                p.warn
1159            };
1160            ListItem::new(Line::from(vec![
1161                Span::styled(
1162                    format!(
1163                        "{:<width$}",
1164                        truncate(&it.next.label, label_w),
1165                        width = label_w + 2
1166                    ),
1167                    Style::default().fg(when_color),
1168                ),
1169                Span::styled(
1170                    format!("{:<width$}", truncate(&it.name, name_w), width = name_w + 2),
1171                    Style::default().fg(p.text),
1172                ),
1173                Span::styled(
1174                    truncate(&it.next.desc, desc_space),
1175                    Style::default().fg(p.dim),
1176                ),
1177            ]))
1178        })
1179        .collect();
1180    let list = List::new(items)
1181        .block(block)
1182        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1183    let mut state = ListState::default().with_selected(Some(u.index));
1184    f.render_stateful_widget(list, popup, &mut state);
1185}
1186
1187fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1188    let Some(picker) = &app.wh_picker else {
1189        return;
1190    };
1191    let warehouses = app.warehouses();
1192    let name_w = warehouses
1193        .iter()
1194        .map(|(n, _, _)| n.chars().count())
1195        .max()
1196        .unwrap_or(12)
1197        .max(12);
1198    // marker(2) + dot(2) + name + gap(2) + state(7) + gap(2) + id(16) + padding/borders(6)
1199    let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
1200    let height = (warehouses.len() as u16 + 4).min(area.height);
1201    let popup = Rect {
1202        x: area.x + (area.width.saturating_sub(width)) / 2,
1203        y: area.y + (area.height.saturating_sub(height)) / 2,
1204        width,
1205        height,
1206    };
1207    f.render_widget(Clear, popup);
1208    let block = Block::default()
1209        .title(Line::from(vec![
1210            Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
1211            Span::styled(
1212                "Run query on ",
1213                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1214            ),
1215        ]))
1216        .borders(Borders::ALL)
1217        .border_type(BorderType::Rounded)
1218        .border_style(
1219            Style::default()
1220                .fg(p.warehouses)
1221                .add_modifier(Modifier::BOLD),
1222        )
1223        .padding(Padding::new(1, 1, 1, 1));
1224    let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
1225    let items: Vec<ListItem> = warehouses
1226        .iter()
1227        .map(|(name, id, running)| {
1228            let dot = if *running { p.ok } else { p.dim };
1229            let marker = if current_id == Some(id.as_str()) {
1230                "» "
1231            } else {
1232                "  "
1233            };
1234            let state = if *running { "running" } else { "idle   " };
1235            ListItem::new(Line::from(vec![
1236                Span::styled(marker, Style::default().fg(p.key)),
1237                Span::styled("● ", Style::default().fg(dot)),
1238                Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
1239                Span::styled(
1240                    format!("  {state}"),
1241                    Style::default().fg(if *running { p.ok } else { p.dim }),
1242                ),
1243                Span::styled(format!("  {id}"), Style::default().fg(p.dim)),
1244            ]))
1245        })
1246        .collect();
1247    let list = List::new(items)
1248        .block(block)
1249        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1250    let mut state = ListState::default().with_selected(Some(picker.index));
1251    f.render_stateful_widget(list, popup, &mut state);
1252}
1253
1254const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
1255const WORDMARK_BOT: &str = "█▄▀ █▀█  █  █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
1256
1257fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1258    let lines_total = 9u16;
1259    let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
1260    let frame = app.spinner_frame();
1261
1262    // Brick-mark pyramid in Databricks red.
1263    let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
1264    for (i, row) in bricks.iter().enumerate() {
1265        let line = Line::from(Span::styled(
1266            *row,
1267            Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
1268        ));
1269        let rect = Rect {
1270            x: area.x,
1271            y: top + i as u16,
1272            width: area.width,
1273            height: 1,
1274        };
1275        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1276    }
1277
1278    // Wordmark with a light sweep that travels across the letters.
1279    let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
1280    for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
1281        let spans: Vec<Span> = text
1282            .chars()
1283            .enumerate()
1284            .map(|(i, c)| {
1285                let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
1286                let style = if dist < 3 {
1287                    Style::default()
1288                        .fg(Color::White)
1289                        .add_modifier(Modifier::BOLD)
1290                } else {
1291                    Style::default().fg(p.brand)
1292                };
1293                Span::styled(c.to_string(), style)
1294            })
1295            .collect();
1296        let rect = Rect {
1297            x: area.x,
1298            y: top + row,
1299            width: area.width,
1300            height: 1,
1301        };
1302        f.render_widget(
1303            Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
1304            rect,
1305        );
1306    }
1307
1308    let tagline = Line::from(vec![
1309        Span::styled("the ", Style::default().fg(p.dim)),
1310        Span::styled(
1311            "Lakehouse",
1312            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1313        ),
1314        Span::styled(" in your terminal", Style::default().fg(p.dim)),
1315    ]);
1316    let version = Line::from(Span::styled(
1317        format!("v{}", env!("CARGO_PKG_VERSION")),
1318        Style::default().fg(p.dim),
1319    ));
1320    for (offset, line) in [(7u16, tagline), (8u16, version)] {
1321        let rect = Rect {
1322            x: area.x,
1323            y: top + offset,
1324            width: area.width,
1325            height: 1,
1326        };
1327        f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
1328    }
1329}
1330
1331fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1332    let selected = app.picker.unwrap_or(0);
1333    let width = (app
1334        .profiles
1335        .iter()
1336        .map(|s| s.len())
1337        .max()
1338        .unwrap_or(10)
1339        .max(20) as u16
1340        + 6)
1341    .min(area.width);
1342    let height = (app.profiles.len() as u16 + 2).min(area.height);
1343    let popup = Rect {
1344        x: area.x + (area.width.saturating_sub(width)) / 2,
1345        y: area.y + (area.height.saturating_sub(height)) / 2,
1346        width,
1347        height,
1348    };
1349    f.render_widget(Clear, popup);
1350    let block = Block::default()
1351        .title(Line::from(Span::styled(
1352            " ⌂ Workspace ",
1353            Style::default().fg(p.key).add_modifier(Modifier::BOLD),
1354        )))
1355        .borders(Borders::ALL)
1356        .border_type(BorderType::Rounded)
1357        .border_style(Style::default().fg(p.key))
1358        .padding(Padding::horizontal(1));
1359    let items: Vec<ListItem> = app
1360        .profiles
1361        .iter()
1362        .map(|name| {
1363            let current = app.profile.as_deref() == Some(name.as_str());
1364            let marker = if current { "● " } else { "  " };
1365            ListItem::new(Line::from(vec![
1366                Span::styled(marker, Style::default().fg(p.ok)),
1367                Span::styled(name.as_str(), Style::default().fg(p.text)),
1368            ]))
1369        })
1370        .collect();
1371    let list = List::new(items)
1372        .block(block)
1373        .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
1374    let mut state = ListState::default().with_selected(Some(selected));
1375    f.render_stateful_widget(list, popup, &mut state);
1376}
1377
1378fn bucket_color(bucket: &str, p: &Palette) -> Color {
1379    match bucket {
1380        "Jobs" => p.jobs,
1381        "SQL" => p.warehouses,
1382        "All-Purpose" => p.clusters,
1383        "DLT" => p.pipelines,
1384        _ => p.dim,
1385    }
1386}
1387
1388fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1389    let cv = app.cost.as_ref().unwrap();
1390    let scope = match &cv.data {
1391        Some(Ok(d)) if d.scoped => " · this workspace",
1392        Some(Ok(_)) => " · all workspaces",
1393        _ => "",
1394    };
1395    let title = Line::from(vec![
1396        Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1397        Span::styled(
1398            format!("Usage · last 14 days{scope} "),
1399            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1400        ),
1401        Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
1402    ]);
1403    let block = Block::default()
1404        .title(title)
1405        .borders(Borders::ALL)
1406        .border_type(BorderType::Rounded)
1407        .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1408        .padding(Padding::new(1, 1, 1, 1));
1409
1410    match &cv.data {
1411        None => {
1412            let par = Paragraph::new(format!(
1413                "{} querying system.billing.usage — the warehouse may need to start…",
1414                app.spinner()
1415            ))
1416            .style(Style::default().fg(p.warn))
1417            .block(block);
1418            f.render_widget(par, area);
1419        }
1420        Some(Err(e)) => {
1421            let par = Paragraph::new(format!(
1422                "✗ {e}\n\nsystem tables need to be enabled and readable \
1423                 (grants on the `system` catalog)"
1424            ))
1425            .style(Style::default().fg(p.err))
1426            .wrap(Wrap { trim: false })
1427            .block(block);
1428            f.render_widget(par, area);
1429        }
1430        Some(Ok(data)) if data.days.is_empty() => {
1431            let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1432                .style(Style::default().fg(p.dim))
1433                .block(block);
1434            f.render_widget(par, area);
1435        }
1436        Some(Ok(data)) => {
1437            let mut lines: Vec<Line> = Vec::new();
1438
1439            // Legend with per-bucket totals, largest first.
1440            let mut legend = vec![Span::raw("")];
1441            for (bucket, total, usd) in &data.buckets {
1442                legend.push(Span::styled(
1443                    "■ ",
1444                    Style::default().fg(bucket_color(bucket, p)),
1445                ));
1446                let amount = if data.priced {
1447                    format!("{bucket} {total:.1} (${usd:.0})   ")
1448                } else {
1449                    format!("{bucket} {total:.1}   ")
1450                };
1451                legend.push(Span::styled(amount, Style::default().fg(p.text)));
1452            }
1453            let sigma = if data.priced {
1454                format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1455            } else {
1456                format!("Σ {:.1} DBU", data.total)
1457            };
1458            legend.push(Span::styled(
1459                sigma,
1460                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1461            ));
1462            lines.push(Line::from(legend));
1463            if data.priced {
1464                lines.push(Line::from(Span::styled(
1465                    " list prices before discounts",
1466                    Style::default().fg(p.dim),
1467                )));
1468            }
1469            if !data.scoped {
1470                lines.push(Line::from(Span::styled(
1471                    " couldn't resolve this workspace's id — showing the whole account",
1472                    Style::default().fg(p.warn),
1473                )));
1474            }
1475            lines.push(Line::default());
1476
1477            let max_day = data
1478                .days
1479                .iter()
1480                .map(|d| d.total)
1481                .fold(0.0_f64, f64::max)
1482                .max(f64::EPSILON);
1483            let inner_w = area.width.saturating_sub(4) as usize;
1484            let bar_w = inner_w.saturating_sub(22).max(10);
1485
1486            for day in &data.days {
1487                let mut spans = vec![Span::styled(
1488                    // "2026-07-01" -> "07-01"
1489                    format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1490                    Style::default().fg(p.dim),
1491                )];
1492                for (bucket, value) in &day.by_bucket {
1493                    let chars = ((value / max_day) * bar_w as f64)
1494                        .round()
1495                        .max(if *value > 0.0 { 1.0 } else { 0.0 })
1496                        as usize;
1497                    spans.push(Span::styled(
1498                        "█".repeat(chars),
1499                        Style::default().fg(bucket_color(bucket, p)),
1500                    ));
1501                }
1502                let day_label = if data.priced {
1503                    format!("  {:.1} · ${:.2}", day.total, day.total_usd)
1504                } else {
1505                    format!("  {:.1}", day.total)
1506                };
1507                spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1508                lines.push(Line::from(spans));
1509            }
1510
1511            if !data.spenders.is_empty() {
1512                lines.push(Line::default());
1513                lines.push(Line::from(Span::styled(
1514                    "TOP SPENDERS · 14 days",
1515                    Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1516                )));
1517                let name_w = data
1518                    .spenders
1519                    .iter()
1520                    .map(|s| {
1521                        app.resource_name(&s.kind, &s.id)
1522                            .unwrap_or_else(|| s.id.clone())
1523                            .chars()
1524                            .count()
1525                    })
1526                    .max()
1527                    .unwrap_or(0)
1528                    .max(8);
1529                for (i, s) in data.spenders.iter().enumerate() {
1530                    let name = app
1531                        .resource_name(&s.kind, &s.id)
1532                        .unwrap_or_else(|| s.id.clone());
1533                    let kind_color = match s.kind.as_str() {
1534                        "job" => p.jobs,
1535                        "cluster" => p.clusters,
1536                        "warehouse" => p.warehouses,
1537                        _ => p.dim,
1538                    };
1539                    let amount = if data.priced {
1540                        format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1541                    } else {
1542                        format!("{:>8.1} DBU", s.dbus)
1543                    };
1544                    lines.push(Line::from(vec![
1545                        Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1546                        Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1547                        Span::styled(
1548                            format!("{:<width$}", name, width = name_w + 2),
1549                            Style::default().fg(p.text),
1550                        ),
1551                        Span::styled(amount, Style::default().fg(p.text)),
1552                    ]));
1553                }
1554            }
1555
1556            let par = Paragraph::new(lines).block(block);
1557            f.render_widget(par, area);
1558        }
1559    }
1560}
1561
1562/// Bars for the per-item spend view: one row per calendar month.
1563fn month_bars(
1564    months: &[(String, f64, f64)],
1565    priced: bool,
1566    width: usize,
1567    p: &Palette,
1568) -> Vec<Line<'static>> {
1569    let max = months
1570        .iter()
1571        .map(|(_, dbus, _)| *dbus)
1572        .fold(0.0_f64, f64::max)
1573        .max(f64::EPSILON);
1574    let bar_w = width.saturating_sub(30).max(10);
1575    months
1576        .iter()
1577        .map(|(month, dbus, usd)| {
1578            let chars =
1579                ((dbus / max) * bar_w as f64)
1580                    .round()
1581                    .max(if *dbus > 0.0 { 1.0 } else { 0.0 }) as usize;
1582            let amount = if priced {
1583                format!("  {dbus:.1} · ${usd:.2}")
1584            } else {
1585                format!("  {dbus:.1}")
1586            };
1587            Line::from(vec![
1588                Span::styled(format!("{month:<9}"), Style::default().fg(p.dim)),
1589                Span::styled("█".repeat(chars), Style::default().fg(p.jobs)),
1590                Span::styled(amount, Style::default().fg(p.text)),
1591            ])
1592        })
1593        .collect()
1594}
1595
1596fn draw_item_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1597    let cv = app.item_cost.as_ref().unwrap();
1598    let scope = match &cv.data {
1599        Some(Ok(d)) if d.scoped => " · this workspace",
1600        Some(Ok(_)) => " · all workspaces",
1601        _ => "",
1602    };
1603    let title = Line::from(vec![
1604        Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
1605        Span::styled(
1606            format!("Spend · {} ", cv.name),
1607            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1608        ),
1609        Span::styled(
1610            format!("· {}{scope} · via {} ", cv.kind.label(), cv.warehouse),
1611            Style::default().fg(p.dim),
1612        ),
1613    ]);
1614    let block = Block::default()
1615        .title(title)
1616        .borders(Borders::ALL)
1617        .border_type(BorderType::Rounded)
1618        .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1619        .padding(Padding::new(1, 1, 1, 1));
1620
1621    match &cv.data {
1622        None => {
1623            let par = Paragraph::new(format!(
1624                "{} querying system.billing.usage — the warehouse may need to start…",
1625                app.spinner()
1626            ))
1627            .style(Style::default().fg(p.warn))
1628            .block(block);
1629            f.render_widget(par, area);
1630        }
1631        Some(Err(e)) => {
1632            let par = Paragraph::new(format!(
1633                "✗ {e}\n\nsystem tables need to be enabled and readable \
1634                 (grants on the `system` catalog)"
1635            ))
1636            .style(Style::default().fg(p.err))
1637            .wrap(Wrap { trim: false })
1638            .block(block);
1639            f.render_widget(par, area);
1640        }
1641        Some(Ok(data)) if data.is_empty() => {
1642            // Usage only carries a job_id for job and serverless compute,
1643            // so an all-purpose job legitimately shows nothing here.
1644            let hint = match data.kind {
1645                fetchers::cost::ResourceKind::Job => {
1646                    "\n\nruns on all-purpose compute aren't attributed to a job id — \
1647                     only job compute and serverless are"
1648                }
1649                fetchers::cost::ResourceKind::Pipeline => "",
1650            };
1651            let par = Paragraph::new(format!(
1652                "∅ no usage attributed to this {} in the last 365 days{hint}",
1653                data.kind.label()
1654            ))
1655            .style(Style::default().fg(p.dim))
1656            .wrap(Wrap { trim: false })
1657            .block(block);
1658            f.render_widget(par, area);
1659        }
1660        Some(Ok(data)) => {
1661            let mut lines: Vec<Line> = vec![Line::from(vec![
1662                Span::styled(
1663                    format!("{:<14}{:>12}", "PERIOD", "DBU"),
1664                    Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1665                ),
1666                Span::styled(
1667                    if data.priced {
1668                        format!("{:>12}{:>14}", "USD", "VS PRIOR")
1669                    } else {
1670                        format!("{:>14}", "VS PRIOR")
1671                    },
1672                    Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1673                ),
1674            ])];
1675            for w in &data.windows {
1676                let mut spans = vec![
1677                    Span::styled(format!("{:<14}", w.label), Style::default().fg(p.text)),
1678                    Span::styled(
1679                        format!("{:>12.1}", w.dbus),
1680                        Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1681                    ),
1682                ];
1683                if data.priced {
1684                    spans.push(Span::styled(
1685                        format!("{:>12}", format!("${:.2}", w.usd)),
1686                        Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1687                    ));
1688                }
1689                // Rising spend is the thing worth noticing, so it is warned.
1690                let (trend, color) = match w.trend(data.priced) {
1691                    Some(pct) if pct > 0.005 => (format!("▲ {:.0}%", pct * 100.0), p.warn),
1692                    Some(pct) if pct < -0.005 => (format!("▼ {:.0}%", -pct * 100.0), p.ok),
1693                    Some(_) => ("~ flat".to_string(), p.dim),
1694                    None => ("—".to_string(), p.dim),
1695                };
1696                spans.push(Span::styled(
1697                    format!("{trend:>14}"),
1698                    Style::default().fg(color),
1699                ));
1700                lines.push(Line::from(spans));
1701            }
1702
1703            lines.push(Line::default());
1704            lines.push(Line::from(Span::styled(
1705                if data.priced {
1706                    " list prices before discounts · trend compares with the preceding window"
1707                } else {
1708                    " trend compares with the preceding window"
1709                },
1710                Style::default().fg(p.dim),
1711            )));
1712            if !data.scoped {
1713                lines.push(Line::from(Span::styled(
1714                    " couldn't resolve this workspace's id — showing the whole account",
1715                    Style::default().fg(p.warn),
1716                )));
1717            }
1718
1719            lines.push(Line::default());
1720            lines.push(Line::from(Span::styled(
1721                "BY MONTH",
1722                Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1723            )));
1724            let inner_w = area.width.saturating_sub(4) as usize;
1725            lines.extend(month_bars(&data.months, data.priced, inner_w, p));
1726
1727            let par = Paragraph::new(lines).block(block);
1728            f.render_widget(par, area);
1729        }
1730    }
1731}
1732
1733fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1734    let console = app.sql.as_ref().unwrap();
1735    let parts = Layout::default()
1736        .direction(Direction::Vertical)
1737        .constraints([Constraint::Length(3), Constraint::Min(0)])
1738        .split(area);
1739
1740    let via = if console.warehouse.is_empty() {
1741        String::new()
1742    } else {
1743        format!("via {} ", console.warehouse)
1744    };
1745    let prompt_block = Block::default()
1746        .title(Line::from(vec![
1747            Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1748            Span::styled(
1749                "SQL Console ",
1750                Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1751            ),
1752            Span::styled(via, Style::default().fg(p.dim)),
1753        ]))
1754        .borders(Borders::ALL)
1755        .border_type(BorderType::Thick)
1756        .border_style(
1757            Style::default()
1758                .fg(p.warehouses)
1759                .add_modifier(Modifier::BOLD),
1760        )
1761        .padding(Padding::horizontal(1));
1762    if let Some((query, _)) = &app.hist_search {
1763        // Readline-style incremental search over history.
1764        let matched = app
1765            .hist_search_current()
1766            .map(|s| s.replace('\n', " "))
1767            .unwrap_or_default();
1768        let mut spans = vec![
1769            Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1770            Span::styled(
1771                query.as_str(),
1772                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1773            ),
1774            Span::styled("`: ", Style::default().fg(p.dim)),
1775        ];
1776        spans.extend(sql_spans(&matched, p));
1777        let prompt = Paragraph::new(Line::from(spans)).block(prompt_block);
1778        f.render_widget(prompt, parts[0]);
1779    } else {
1780        // Caret sits at the cursor, not the end; long inputs scroll so
1781        // the caret stays visible. Newlines from $EDITOR render as
1782        // spaces (byte-for-byte, so the caret math holds).
1783        let caret_byte = console
1784            .input
1785            .char_indices()
1786            .nth(console.cursor)
1787            .map(|(i, _)| i)
1788            .unwrap_or(console.input.len());
1789        let (before, after) = console.input.split_at(caret_byte);
1790        let inner_w = parts[0].width.saturating_sub(4) as usize; // borders + padding
1791        let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1792        // Highlight each side of the caret separately — a token split by
1793        // the caret loses its color for a keystroke, which is fine.
1794        let mut spans = vec![Span::styled("❯ ", Style::default().fg(p.key))];
1795        spans.extend(sql_spans(&before.replace('\n', " "), p));
1796        spans.push(Span::styled("▏", Style::default().fg(p.warn)));
1797        spans.extend(sql_spans(&after.replace('\n', " "), p));
1798        let prompt = Paragraph::new(Line::from(spans))
1799            .scroll((0, hscroll))
1800            .block(prompt_block);
1801        f.render_widget(prompt, parts[0]);
1802    }
1803
1804    let row_info = match &console.data {
1805        Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1806        _ => String::new(),
1807    };
1808    let mut results_title = vec![
1809        Span::styled(" Results ", Style::default().fg(p.text)),
1810        Span::styled(row_info, Style::default().fg(p.dim)),
1811    ];
1812    // Wide results page horizontally; say where we are.
1813    let avail = parts[1].width.saturating_sub(4) as usize;
1814    let sliced = match &console.data {
1815        Some(Ok(t)) if console.record && !t.rows.is_empty() => {
1816            let row_n = console.scroll.min(t.rows.len().saturating_sub(1));
1817            results_title.push(Span::styled(
1818                format!("· row {}/{} (⇧←/→) ", row_n + 1, t.rows.len()),
1819                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1820            ));
1821            None
1822        }
1823        Some(Ok(t)) if !t.headers.is_empty() => {
1824            let cols: Vec<usize> = (0..t.headers.len()).collect();
1825            let (shown, constraints) = grid_slice(t, &cols, console.col, avail);
1826            if shown.len() < cols.len() {
1827                let first = console.col.min(cols.len() - 1) + 1;
1828                results_title.push(Span::styled(
1829                    format!(
1830                        "· cols {}–{} of {} (⇧←/→) ",
1831                        first,
1832                        first + shown.len() - 1,
1833                        cols.len()
1834                    ),
1835                    Style::default().fg(p.dim),
1836                ));
1837            }
1838            Some((shown, constraints))
1839        }
1840        _ => None,
1841    };
1842    let results_block = Block::default()
1843        .title(Line::from(results_title))
1844        .borders(Borders::ALL)
1845        .border_type(BorderType::Rounded)
1846        .border_style(Style::default().fg(p.border))
1847        .padding(Padding::horizontal(1));
1848
1849    if console.running {
1850        let par = Paragraph::new(format!(
1851            "{} running on {} — the warehouse may need a moment to start…",
1852            app.spinner(),
1853            console.warehouse
1854        ))
1855        .style(Style::default().fg(p.warn))
1856        .block(results_block);
1857        f.render_widget(par, parts[1]);
1858        return;
1859    }
1860    match &console.data {
1861        None => {
1862            let par = Paragraph::new(
1863                "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1864            )
1865            .style(Style::default().fg(p.dim))
1866            .block(results_block);
1867            f.render_widget(par, parts[1]);
1868        }
1869        Some(Err(e)) => {
1870            let par = Paragraph::new(format!("✗ {e}"))
1871                .style(Style::default().fg(p.err))
1872                .wrap(Wrap { trim: false })
1873                .block(results_block);
1874            f.render_widget(par, parts[1]);
1875        }
1876        Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1877            let par = Paragraph::new("✓ statement succeeded — no result set")
1878                .style(Style::default().fg(p.ok))
1879                .block(results_block);
1880            f.render_widget(par, parts[1]);
1881        }
1882        // Transposed: one row, fields stacked — the readable way through
1883        // a result too wide to page across.
1884        Some(Ok(data)) if console.record && !data.rows.is_empty() => {
1885            let row_n = console.scroll.min(data.rows.len().saturating_sub(1));
1886            let name_w = data
1887                .headers
1888                .iter()
1889                .map(|h| h.chars().count())
1890                .max()
1891                .unwrap_or(0)
1892                .min(32);
1893            let empty: Vec<String> = Vec::new();
1894            let row = data.rows.get(row_n).unwrap_or(&empty);
1895            let lines: Vec<Line> = data
1896                .headers
1897                .iter()
1898                .enumerate()
1899                .map(|(i, header)| {
1900                    let value = row.get(i).map(String::as_str).unwrap_or("");
1901                    let vstyle = if value == "␀" {
1902                        Style::default().fg(p.dim)
1903                    } else {
1904                        Style::default().fg(p.text)
1905                    };
1906                    Line::from(vec![
1907                        Span::styled(
1908                            format!("{header:<name_w$}  "),
1909                            Style::default().fg(p.warehouses),
1910                        ),
1911                        Span::styled(value.to_string(), vstyle),
1912                    ])
1913                })
1914                .collect();
1915            let total = lines.len();
1916            let par = Paragraph::new(lines)
1917                .scroll((console.rscroll, 0))
1918                .block(results_block);
1919            f.render_widget(par, parts[1]);
1920            scrollbar(
1921                f,
1922                parts[1],
1923                total,
1924                parts[1].height.saturating_sub(2) as usize,
1925                console.rscroll as usize,
1926                p,
1927            );
1928        }
1929        Some(Ok(data)) => {
1930            let (shown, constraints) = sliced.unwrap_or_default();
1931            let header_cells: Vec<Cell> = shown
1932                .iter()
1933                .map(|&i| {
1934                    Cell::from(data.headers[i].as_str()).style(
1935                        Style::default()
1936                            .fg(p.warehouses)
1937                            .add_modifier(Modifier::BOLD),
1938                    )
1939                })
1940                .collect();
1941            let header = Row::new(header_cells);
1942            let rows: Vec<Row> = data
1943                .rows
1944                .iter()
1945                .skip(console.scroll)
1946                .map(|r| {
1947                    Row::new(
1948                        shown
1949                            .iter()
1950                            .map(|&i| {
1951                                Cell::from(r.get(i).map(String::as_str).unwrap_or(""))
1952                                    .style(Style::default().fg(p.text))
1953                            })
1954                            .collect::<Vec<_>>(),
1955                    )
1956                })
1957                .collect();
1958            let table = Table::new(rows, constraints)
1959                .header(header)
1960                .column_spacing(1)
1961                .block(results_block);
1962            f.render_widget(table, parts[1]);
1963            // Borders plus the header row eat three lines of the viewport.
1964            scrollbar(
1965                f,
1966                parts[1],
1967                data.rows.len(),
1968                parts[1].height.saturating_sub(3) as usize,
1969                console.scroll,
1970                p,
1971            );
1972        }
1973    }
1974}
1975
1976/// Tab-completion popup, anchored under the segment being completed.
1977fn draw_sql_complete(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1978    let (Some(console), Some(comp)) = (&app.sql, &app.sql_complete) else {
1979        return;
1980    };
1981    // Mirror the prompt geometry from draw_sql to find the anchor column.
1982    let inner_w = area.width.saturating_sub(4) as usize;
1983    let hscroll = (console.cursor + 3).saturating_sub(inner_w);
1984    let anchor = (area.x as usize + 4 + comp.seg_start.saturating_sub(hscroll)) as u16;
1985
1986    let lines: Vec<Line> = if comp.loading {
1987        vec![Line::from(Span::styled(
1988            format!("{} fetching names…", app.spinner()),
1989            Style::default().fg(p.warn),
1990        ))]
1991    } else {
1992        // Keep the highlighted candidate inside the window.
1993        let visible = 8.min(comp.items.len());
1994        let skip = comp.index.saturating_sub(visible.saturating_sub(1));
1995        comp.items
1996            .iter()
1997            .enumerate()
1998            .skip(skip)
1999            .take(visible)
2000            .map(|(i, item)| {
2001                let style = if i == comp.index {
2002                    Style::default()
2003                        .fg(p.key)
2004                        .add_modifier(Modifier::REVERSED | Modifier::BOLD)
2005                } else {
2006                    Style::default().fg(p.text)
2007                };
2008                Line::from(Span::styled(format!(" {item} "), style))
2009            })
2010            .collect()
2011    };
2012    let widest = comp
2013        .items
2014        .iter()
2015        .map(|i| i.chars().count())
2016        .max()
2017        .unwrap_or(16);
2018    let width = (widest as u16 + 4).clamp(20, 44).min(area.width);
2019    let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(3));
2020    let popup = Rect {
2021        x: anchor.min(area.x + area.width.saturating_sub(width)),
2022        y: area.y + 3,
2023        width,
2024        height,
2025    };
2026    let title = if comp.loading {
2027        String::new()
2028    } else {
2029        format!(" {}/{} ", comp.index + 1, comp.items.len())
2030    };
2031    let block = Block::default()
2032        .title(Span::styled(title, Style::default().fg(p.dim)))
2033        .borders(Borders::ALL)
2034        .border_type(BorderType::Rounded)
2035        .border_style(Style::default().fg(p.key));
2036    f.render_widget(Clear, popup);
2037    f.render_widget(Paragraph::new(lines).block(block), popup);
2038}
2039
2040/// Natural display width of each column in `cols`: the widest of header
2041/// and sampled cells, clamped so one huge value can't eat the pane.
2042fn grid_widths(data: &TableData, cols: &[usize]) -> Vec<u16> {
2043    cols.iter()
2044        .map(|&i| {
2045            let mut w = data.headers.get(i).map(|h| h.chars().count()).unwrap_or(0);
2046            for row in data.rows.iter().take(50) {
2047                if let Some(c) = row.get(i) {
2048                    w = w.max(c.chars().count());
2049                }
2050            }
2051            w.clamp(4, 36) as u16
2052        })
2053        .collect()
2054}
2055
2056/// The slice of `cols` that fits in `avail` starting at offset `col`,
2057/// with Length constraints — at least one column so the grid never
2058/// vanishes. Wide results page through here instead of squeezing every
2059/// column into an unreadable sliver.
2060fn grid_slice(
2061    data: &TableData,
2062    cols: &[usize],
2063    col: usize,
2064    avail: usize,
2065) -> (Vec<usize>, Vec<Constraint>) {
2066    let widths = grid_widths(data, cols);
2067    let col = col.min(cols.len().saturating_sub(1));
2068    let mut take = 0usize;
2069    let mut used = 0usize;
2070    for w in widths.iter().skip(col) {
2071        let next = used + *w as usize + if take > 0 { 1 } else { 0 };
2072        if take > 0 && next > avail {
2073            break;
2074        }
2075        used = next;
2076        take += 1;
2077    }
2078    let shown: Vec<usize> = cols.iter().skip(col).take(take).cloned().collect();
2079    let constraints: Vec<Constraint> = widths
2080        .iter()
2081        .skip(col)
2082        .take(take)
2083        .map(|&w| Constraint::Length(w))
2084        .collect();
2085    (shown, constraints)
2086}
2087
2088fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2089    let pv = app.preview.as_ref().unwrap();
2090    let acc = p.catalog;
2091    let mut title_spans: Vec<Span<'static>> = vec![
2092        Span::styled(" ◫ ", Style::default().fg(acc)),
2093        Span::styled(
2094            format!("{} · preview ", pv.name),
2095            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2096        ),
2097        Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
2098    ];
2099    let make_block = |spans: Vec<Span<'static>>| {
2100        Block::default()
2101            .title(Line::from(spans))
2102            .borders(Borders::ALL)
2103            .border_type(BorderType::Rounded)
2104            .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2105            .padding(Padding::horizontal(1))
2106    };
2107
2108    match &pv.data {
2109        None => {
2110            let par = Paragraph::new(format!(
2111                "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
2112                app.spinner()
2113            ))
2114            .style(Style::default().fg(p.warn))
2115            .block(make_block(title_spans));
2116            f.render_widget(par, area);
2117        }
2118        Some(Err(e)) => {
2119            let text = format!(
2120                "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
2121                pv.warehouse,
2122                pv.warehouse_id,
2123                app.profile.as_deref().unwrap_or("default"),
2124                app.host.as_deref().unwrap_or("unknown"),
2125            );
2126            let par = Paragraph::new(text)
2127                .style(Style::default().fg(p.err))
2128                .wrap(Wrap { trim: false })
2129                .block(make_block(title_spans));
2130            f.render_widget(par, area);
2131        }
2132        Some(Ok(data)) => {
2133            title_spans.push(Span::styled(
2134                format!("· {} rows ", data.rows.len()),
2135                Style::default().fg(p.dim),
2136            ));
2137            let cols = pv.visible_cols();
2138            if !pv.filter.is_empty() {
2139                title_spans.push(Span::styled(
2140                    format!("· cols /{} ", pv.filter),
2141                    Style::default().fg(p.warn),
2142                ));
2143            }
2144            if cols.is_empty() {
2145                let par = Paragraph::new(format!(
2146                    "no columns match /{} — backspace to widen, esc to clear",
2147                    pv.filter
2148                ))
2149                .style(Style::default().fg(p.warn))
2150                .block(make_block(title_spans));
2151                f.render_widget(par, area);
2152                return;
2153            }
2154            if pv.record {
2155                // Transposed: one row, fields stacked — the readable way
2156                // through a table with hundreds of columns.
2157                let row_n = pv.scroll.min(data.rows.len().saturating_sub(1));
2158                title_spans.push(Span::styled(
2159                    format!("· row {}/{} ", row_n + 1, data.rows.len()),
2160                    Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2161                ));
2162                let name_w = cols
2163                    .iter()
2164                    .map(|&i| data.headers[i].chars().count())
2165                    .max()
2166                    .unwrap_or(0)
2167                    .min(32);
2168                let empty: Vec<String> = Vec::new();
2169                let row = data.rows.get(row_n).unwrap_or(&empty);
2170                let lines: Vec<Line> = cols
2171                    .iter()
2172                    .map(|&i| {
2173                        let value = row.get(i).map(String::as_str).unwrap_or("");
2174                        let vstyle = if value == "␀" {
2175                            Style::default().fg(p.dim)
2176                        } else {
2177                            Style::default().fg(p.text)
2178                        };
2179                        Line::from(vec![
2180                            Span::styled(
2181                                format!("{:<name_w$}  ", data.headers[i]),
2182                                Style::default().fg(acc),
2183                            ),
2184                            Span::styled(value.to_string(), vstyle),
2185                        ])
2186                    })
2187                    .collect();
2188                let total = lines.len();
2189                let par = Paragraph::new(lines)
2190                    .scroll((pv.rscroll, 0))
2191                    .block(make_block(title_spans));
2192                f.render_widget(par, area);
2193                scrollbar(
2194                    f,
2195                    area,
2196                    total,
2197                    area.height.saturating_sub(2) as usize,
2198                    pv.rscroll as usize,
2199                    p,
2200                );
2201                return;
2202            }
2203            let avail = area.width.saturating_sub(4) as usize; // borders + padding
2204            let (shown, constraints) = grid_slice(data, &cols, pv.col, avail);
2205            if shown.len() < cols.len() {
2206                let first = pv.col.min(cols.len() - 1) + 1;
2207                title_spans.push(Span::styled(
2208                    format!(
2209                        "· cols {}–{} of {} ",
2210                        first,
2211                        first + shown.len() - 1,
2212                        cols.len()
2213                    ),
2214                    Style::default().fg(p.dim),
2215                ));
2216            }
2217            let header = Row::new(
2218                shown
2219                    .iter()
2220                    .map(|&i| {
2221                        Cell::from(data.headers[i].clone())
2222                            .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2223                    })
2224                    .collect::<Vec<_>>(),
2225            );
2226            let rows: Vec<Row> = data
2227                .rows
2228                .iter()
2229                .skip(pv.scroll)
2230                .map(|r| {
2231                    Row::new(
2232                        shown
2233                            .iter()
2234                            .map(|&i| {
2235                                Cell::from(r.get(i).cloned().unwrap_or_default())
2236                                    .style(Style::default().fg(p.text))
2237                            })
2238                            .collect::<Vec<_>>(),
2239                    )
2240                })
2241                .collect();
2242            let table = Table::new(rows, constraints)
2243                .header(header)
2244                .column_spacing(1)
2245                .block(make_block(title_spans));
2246            f.render_widget(table, area);
2247            // Borders plus the header row eat three lines of the viewport.
2248            scrollbar(
2249                f,
2250                area,
2251                data.rows.len(),
2252                area.height.saturating_sub(3) as usize,
2253                pv.scroll,
2254                p,
2255            );
2256        }
2257    }
2258}
2259
2260fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2261    let d = app.detail.as_ref().unwrap();
2262    let acc = accent(d.panel, p);
2263    let title = Line::from(vec![
2264        Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
2265        Span::styled(
2266            format!("{} · {} ", d.panel.title(), d.name),
2267            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2268        ),
2269    ]);
2270    let block = Block::default()
2271        .title(title)
2272        .borders(Borders::ALL)
2273        .border_type(BorderType::Rounded)
2274        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2275        .padding(Padding::horizontal(1));
2276
2277    let Some(data) = &d.data else {
2278        let par = Paragraph::new(format!("{} Loading…", app.spinner()))
2279            .style(Style::default().fg(p.warn))
2280            .block(block);
2281        f.render_widget(par, area);
2282        return;
2283    };
2284
2285    // Fall back to raw when there's nothing structured to show (e.g. errors).
2286    if d.show_raw || data.summary.is_empty() {
2287        let par = Paragraph::new(data.raw.as_str())
2288            .style(Style::default().fg(p.text))
2289            .wrap(Wrap { trim: false })
2290            .scroll((d.scroll, 0))
2291            .block(block);
2292        f.render_widget(par, area);
2293        let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2294        scrollbar(
2295            f,
2296            area,
2297            total,
2298            area.height.saturating_sub(2) as usize,
2299            d.scroll as usize,
2300            p,
2301        );
2302        return;
2303    }
2304
2305    let mut lines: Vec<Line> = data
2306        .summary
2307        .iter()
2308        .map(|(k, v)| {
2309            Line::from(vec![
2310                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2311                Span::styled(v.as_str(), Style::default().fg(p.text)),
2312            ])
2313        })
2314        .collect();
2315    if !data.activity.is_empty() {
2316        lines.push(Line::default());
2317        lines.push(Line::from(Span::styled(
2318            d.section,
2319            Style::default().fg(acc).add_modifier(Modifier::BOLD),
2320        )));
2321        for (status, text) in &data.activity {
2322            // Lineage tree guides carry their own structure — no dot.
2323            let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
2324            if treeish {
2325                lines.push(Line::from(Span::styled(
2326                    text.as_str(),
2327                    Style::default().fg(status_color(status, p)),
2328                )));
2329            } else {
2330                lines.push(Line::from(vec![
2331                    Span::styled("● ", Style::default().fg(status_color(status, p))),
2332                    Span::styled(text.as_str(), Style::default().fg(p.text)),
2333                ]));
2334            }
2335        }
2336    }
2337    let total = lines.len();
2338    let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
2339    f.render_widget(par, area);
2340    scrollbar(
2341        f,
2342        area,
2343        total,
2344        area.height.saturating_sub(2) as usize,
2345        d.scroll as usize,
2346        p,
2347    );
2348}
2349
2350fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2351    let rv = app.run_view.as_ref().unwrap();
2352    let acc = accent(rv.panel, p);
2353    let noun = if rv.panel == Panel::Jobs {
2354        "run"
2355    } else {
2356        "update"
2357    };
2358    let mut title_spans = vec![
2359        Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
2360        Span::styled(
2361            format!("{} · {noun} ", rv.owner_name),
2362            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2363        ),
2364    ];
2365    if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
2366        title_spans.push(Span::styled(
2367            format!("{run_id} "),
2368            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2369        ));
2370        title_spans.push(Span::styled(
2371            format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
2372            Style::default().fg(p.dim),
2373        ));
2374    }
2375    if rv.live {
2376        title_spans.push(Span::styled(
2377            format!("{} live ", app.spinner()),
2378            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2379        ));
2380    }
2381    if rv.show_output {
2382        title_spans.push(Span::styled(
2383            if rv.live {
2384                "· output (tailing) "
2385            } else {
2386                "· output "
2387            },
2388            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2389        ));
2390    }
2391    if rv.show_timeline {
2392        title_spans.push(Span::styled(
2393            "· timeline ",
2394            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2395        ));
2396    }
2397    if rv.show_dag {
2398        title_spans.push(Span::styled(
2399            "· dag ",
2400            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2401        ));
2402    }
2403    if rv.show_grid {
2404        title_spans.push(Span::styled(
2405            "· history grid ",
2406            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2407        ));
2408    }
2409    let block = Block::default()
2410        .title(Line::from(title_spans))
2411        .borders(Borders::ALL)
2412        .border_type(BorderType::Rounded)
2413        .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
2414        .padding(Padding::horizontal(1));
2415
2416    // Full task output/logs, layered over the run detail.
2417    if rv.show_output {
2418        let par = match &rv.output {
2419            Some(text) => {
2420                let lines: Vec<Line> = text.lines().map(|l| output_line(l, p)).collect();
2421                Paragraph::new(lines)
2422                    .wrap(Wrap { trim: false })
2423                    .scroll((rv.scroll, 0))
2424                    .block(block)
2425            }
2426            None => Paragraph::new(format!(
2427                "{} fetching task output — one call per task…",
2428                app.spinner()
2429            ))
2430            .style(Style::default().fg(p.warn))
2431            .block(block),
2432        };
2433        f.render_widget(par, area);
2434        if let Some(text) = &rv.output {
2435            let total = wrapped_height(text, area.width.saturating_sub(4) as usize);
2436            scrollbar(
2437                f,
2438                area,
2439                total,
2440                area.height.saturating_sub(2) as usize,
2441                rv.scroll as usize,
2442                p,
2443            );
2444        }
2445        return;
2446    }
2447
2448    if rv.show_grid {
2449        draw_run_grid(f, area, rv, app, block, p);
2450        return;
2451    }
2452
2453    let Some(data) = &rv.data else {
2454        let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
2455            .style(Style::default().fg(p.warn))
2456            .block(block);
2457        f.render_widget(par, area);
2458        return;
2459    };
2460
2461    if rv.show_timeline {
2462        draw_run_timeline(f, area, rv.scroll, data, block, p);
2463        return;
2464    }
2465
2466    if rv.show_dag {
2467        draw_run_dag(f, area, rv.scroll, data, block, p);
2468        return;
2469    }
2470
2471    if rv.show_raw || data.summary.is_empty() {
2472        let par = Paragraph::new(data.raw.as_str())
2473            .style(Style::default().fg(p.text))
2474            .wrap(Wrap { trim: false })
2475            .scroll((rv.scroll, 0))
2476            .block(block);
2477        f.render_widget(par, area);
2478        let total = wrapped_height(&data.raw, area.width.saturating_sub(4) as usize);
2479        scrollbar(
2480            f,
2481            area,
2482            total,
2483            area.height.saturating_sub(2) as usize,
2484            rv.scroll as usize,
2485            p,
2486        );
2487        return;
2488    }
2489
2490    let mut lines: Vec<Line> = data
2491        .summary
2492        .iter()
2493        .map(|(k, v)| {
2494            Line::from(vec![
2495                Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
2496                Span::styled(v.as_str(), Style::default().fg(p.text)),
2497            ])
2498        })
2499        .collect();
2500    if !data.activity.is_empty() {
2501        lines.push(Line::default());
2502        lines.push(Line::from(Span::styled(
2503            if rv.panel == Panel::Jobs {
2504                "Tasks"
2505            } else {
2506                "Event log"
2507            },
2508            Style::default().fg(acc).add_modifier(Modifier::BOLD),
2509        )));
2510        for (status, text) in &data.activity {
2511            // Error continuation lines carry their own ↳ marker.
2512            if text.starts_with("  ↳") {
2513                lines.push(Line::from(Span::styled(
2514                    text.as_str(),
2515                    Style::default().fg(p.err),
2516                )));
2517            } else {
2518                lines.push(Line::from(vec![
2519                    Span::styled("● ", Style::default().fg(status_color(status, p))),
2520                    Span::styled(text.as_str(), Style::default().fg(p.text)),
2521                ]));
2522            }
2523        }
2524    }
2525    let total = lines.len();
2526    let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2527    f.render_widget(par, area);
2528    scrollbar(
2529        f,
2530        area,
2531        total,
2532        area.height.saturating_sub(2) as usize,
2533        rv.scroll as usize,
2534        p,
2535    );
2536}
2537
2538/// Gantt chart of a job run: one bar per task on a shared time axis,
2539/// colored by state, so the long pole is visible at a glance.
2540fn draw_run_timeline(
2541    f: &mut Frame,
2542    area: Rect,
2543    scroll: u16,
2544    data: &DetailData,
2545    block: Block,
2546    p: &Palette,
2547) {
2548    let tasks = crate::fetchers::runs::timeline(&data.raw);
2549    let now = std::time::SystemTime::now()
2550        .duration_since(std::time::UNIX_EPOCH)
2551        .map(|d| d.as_millis() as u64)
2552        .unwrap_or(0);
2553    let t0 = tasks.iter().filter(|t| t.start > 0).map(|t| t.start).min();
2554    let Some(t0) = t0 else {
2555        let par = Paragraph::new(
2556            "no per-task timing recorded — legacy single-task runs carry none (J shows the raw JSON)",
2557        )
2558        .style(Style::default().fg(p.dim))
2559        .wrap(Wrap { trim: false })
2560        .block(block);
2561        f.render_widget(par, area);
2562        return;
2563    };
2564    let t1 = tasks
2565        .iter()
2566        .filter(|t| t.start > 0)
2567        .map(|t| t.end.unwrap_or(now).max(t.start))
2568        .max()
2569        .unwrap_or(t0);
2570    let span = (t1 - t0).max(1);
2571
2572    let name_w = tasks
2573        .iter()
2574        .map(|t| t.key.chars().count())
2575        .max()
2576        .unwrap_or(4)
2577        .clamp(4, 24);
2578    let inner_w = area.width.saturating_sub(4) as usize;
2579    // Room for the widest duration ("59m 59s…" = 8) after the bar.
2580    let bar_w = inner_w.saturating_sub(name_w + 2 + 9).max(10);
2581
2582    let mut lines: Vec<Line> = vec![
2583        Line::from(Span::styled(
2584            format!(
2585                "◷ {} task{} · span {}",
2586                tasks.len(),
2587                if tasks.len() == 1 { "" } else { "s" },
2588                crate::shape::fmt_duration_ms(span)
2589            ),
2590            Style::default().fg(p.dim),
2591        )),
2592        Line::default(),
2593    ];
2594    for t in &tasks {
2595        let mut key: String = t.key.chars().take(name_w).collect();
2596        if key.len() < t.key.len() {
2597            key.pop();
2598            key.push('…');
2599        }
2600        let name = Span::styled(format!("{key:<name_w$}  "), Style::default().fg(p.text));
2601        if t.start == 0 {
2602            lines.push(Line::from(vec![
2603                name,
2604                Span::styled(
2605                    format!("– {}", t.status.label().to_lowercase()),
2606                    Style::default().fg(p.dim),
2607                ),
2608            ]));
2609            continue;
2610        }
2611        let end = t.end.unwrap_or(now).max(t.start);
2612        let off = ((t.start - t0) as f64 / span as f64 * bar_w as f64) as usize;
2613        let len = (((end - t.start) as f64 / span as f64) * bar_w as f64).round() as usize;
2614        let len = len.clamp(1, bar_w.saturating_sub(off).max(1));
2615        let dur = format!(
2616            " {}{}",
2617            crate::shape::fmt_duration_ms(end - t.start),
2618            if t.end.is_none() { "…" } else { "" }
2619        );
2620        lines.push(Line::from(vec![
2621            name,
2622            Span::styled(" ".repeat(off), Style::default()),
2623            Span::styled(
2624                "█".repeat(len),
2625                Style::default().fg(status_color(&t.status, p)),
2626            ),
2627            Span::styled(dur, Style::default().fg(p.dim)),
2628        ]));
2629    }
2630    let total = lines.len();
2631    let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2632    f.render_widget(par, area);
2633    scrollbar(
2634        f,
2635        area,
2636        total,
2637        area.height.saturating_sub(2) as usize,
2638        scroll as usize,
2639        p,
2640    );
2641}
2642
2643/// Dependency tree of a job run's tasks: each task under the task it
2644/// waits for, colored by state, with extra dependencies annotated.
2645fn draw_run_dag(
2646    f: &mut Frame,
2647    area: Rect,
2648    scroll: u16,
2649    data: &DetailData,
2650    block: Block,
2651    p: &Palette,
2652) {
2653    let rows = crate::fetchers::runs::dag(&data.raw);
2654    if rows.is_empty() {
2655        let par = Paragraph::new(
2656            "no task graph recorded — legacy single-task runs carry none (J shows the raw JSON)",
2657        )
2658        .style(Style::default().fg(p.dim))
2659        .wrap(Wrap { trim: false })
2660        .block(block);
2661        f.render_widget(par, area);
2662        return;
2663    }
2664    let mut lines: Vec<Line> = vec![
2665        Line::from(Span::styled(
2666            format!(
2667                "⧉ {} task{} · order of execution, top to bottom",
2668                rows.len(),
2669                if rows.len() == 1 { "" } else { "s" },
2670            ),
2671            Style::default().fg(p.dim),
2672        )),
2673        Line::default(),
2674    ];
2675    for r in &rows {
2676        let mut spans = vec![
2677            Span::styled(r.prefix.clone(), Style::default().fg(p.dim)),
2678            Span::styled("● ", Style::default().fg(status_color(&r.status, p))),
2679            Span::styled(r.key.clone(), Style::default().fg(p.text)),
2680            Span::styled(
2681                format!("  {}", r.status.label().to_lowercase()),
2682                Style::default().fg(status_color(&r.status, p)),
2683            ),
2684        ];
2685        if let Some(d) = r.duration {
2686            spans.push(Span::styled(
2687                format!("  ·  {}", crate::shape::fmt_duration_ms(d)),
2688                Style::default().fg(p.dim),
2689            ));
2690        }
2691        if !r.also_after.is_empty() {
2692            spans.push(Span::styled(
2693                format!("  (also after {})", r.also_after.join(", ")),
2694                Style::default().fg(p.dim),
2695            ));
2696        }
2697        lines.push(Line::from(spans));
2698    }
2699    let total = lines.len();
2700    let par = Paragraph::new(lines).scroll((scroll, 0)).block(block);
2701    f.render_widget(par, area);
2702    scrollbar(
2703        f,
2704        area,
2705        total,
2706        area.height.saturating_sub(2) as usize,
2707        scroll as usize,
2708        p,
2709    );
2710}
2711
2712/// Compact duration sparkline; scales min..max across eight bar heights
2713/// so small drifts stay visible.
2714fn spark(vals: &[u64]) -> String {
2715    const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2716    let (min, max) = vals
2717        .iter()
2718        .fold((u64::MAX, 0), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
2719    vals.iter()
2720        .map(|v| {
2721            if max == min {
2722                '▄'
2723            } else {
2724                BARS[((v - min) * 7 / (max - min)) as usize]
2725            }
2726        })
2727        .collect()
2728}
2729
2730/// Airflow-style history grid: every task's state across the job's
2731/// recent runs, one row per task, oldest run left. Rows end with a
2732/// duration sparkline over the task's successful runs, flagged when the
2733/// newest is well above the median — the flaky-task and creeping-slowdown
2734/// finder.
2735fn draw_run_grid(
2736    f: &mut Frame,
2737    area: Rect,
2738    rv: &crate::app::RunView,
2739    app: &App,
2740    block: Block,
2741    p: &Palette,
2742) {
2743    let g = match &rv.grid {
2744        None => {
2745            let par = Paragraph::new(format!("{} loading run history…", app.spinner()))
2746                .style(Style::default().fg(p.warn))
2747                .block(block);
2748            f.render_widget(par, area);
2749            return;
2750        }
2751        Some(Err(e)) => {
2752            let par = Paragraph::new(format!("✗ {e}"))
2753                .style(Style::default().fg(p.err))
2754                .wrap(Wrap { trim: false })
2755                .block(block);
2756            f.render_widget(par, area);
2757            return;
2758        }
2759        Some(Ok(g)) => g,
2760    };
2761    if g.tasks.is_empty() {
2762        let par = Paragraph::new(
2763            "no per-task history recorded — legacy single-task runs carry none (J shows the raw JSON)",
2764        )
2765        .style(Style::default().fg(p.dim))
2766        .wrap(Wrap { trim: false })
2767        .block(block);
2768        f.render_widget(par, area);
2769        return;
2770    }
2771
2772    let name_w = g
2773        .tasks
2774        .iter()
2775        .map(|t| t.key.chars().count())
2776        .max()
2777        .unwrap_or(4)
2778        .clamp(4, 24);
2779    let inner_w = area.width.saturating_sub(4) as usize;
2780    // Trailing trend: "  " + sparkline (≤10) + " " + duration (≤8) + flag (≤7).
2781    let trend_w = 28;
2782    // When the terminal is narrow, drop the oldest columns first.
2783    let cols_fit = (inner_w.saturating_sub(name_w + 2 + trend_w) / 2).max(1);
2784    let skip = g.runs.len().saturating_sub(cols_fit);
2785    let cols = &g.runs[skip..];
2786
2787    let mut lines: Vec<Line> = vec![
2788        Line::from(Span::styled(
2789            format!(
2790                "⊞ {} task{} × last {} run{} · oldest → newest",
2791                g.tasks.len(),
2792                if g.tasks.len() == 1 { "" } else { "s" },
2793                cols.len(),
2794                if cols.len() == 1 { "" } else { "s" },
2795            ),
2796            Style::default().fg(p.dim),
2797        )),
2798        Line::default(),
2799    ];
2800
2801    // Marker over the column of the run currently shown behind the grid.
2802    let sel = rv.runs.get(rv.idx).map(|(id, _, _)| id.as_str());
2803    if let Some(ci) = sel.and_then(|id| cols.iter().position(|r| r.run_id == id)) {
2804        let acc = accent(Panel::Jobs, p);
2805        lines.push(Line::from(vec![
2806            Span::raw(" ".repeat(name_w + 2 + 2 * ci)),
2807            Span::styled("▾", Style::default().fg(acc).add_modifier(Modifier::BOLD)),
2808        ]));
2809    }
2810
2811    let mut run_row = vec![Span::styled(
2812        format!("{:<name_w$}  ", "run"),
2813        Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
2814    )];
2815    for r in cols {
2816        run_row.push(Span::styled(
2817            format!("{} ", history_glyph(&r.status)),
2818            Style::default().fg(status_color(&r.status, p)),
2819        ));
2820    }
2821    if let Some(newest) = cols.last() {
2822        run_row.push(Span::styled(
2823            format!("  newest {}", newest.age),
2824            Style::default().fg(p.dim),
2825        ));
2826    }
2827    lines.push(Line::from(run_row));
2828    lines.push(Line::default());
2829
2830    for t in &g.tasks {
2831        let mut key: String = t.key.chars().take(name_w).collect();
2832        if key.chars().count() < t.key.chars().count() {
2833            key.pop();
2834            key.push('…');
2835        }
2836        let mut spans = vec![Span::styled(
2837            format!("{key:<name_w$}  "),
2838            Style::default().fg(p.text),
2839        )];
2840        for cell in &t.cells[skip..] {
2841            spans.push(match cell {
2842                Some(s) => Span::styled(
2843                    format!("{} ", history_glyph(s)),
2844                    Style::default().fg(status_color(s, p)),
2845                ),
2846                None => Span::styled("· ", Style::default().fg(p.dim)),
2847            });
2848        }
2849        let ds = t.success_durations();
2850        if let Some(latest) = ds.last() {
2851            let window = &ds[ds.len().saturating_sub(10)..];
2852            spans.push(Span::styled(
2853                format!("  {}", spark(window)),
2854                Style::default().fg(p.dim),
2855            ));
2856            spans.push(Span::styled(
2857                format!(" {}", crate::shape::fmt_duration_ms(*latest)),
2858                Style::default().fg(p.text),
2859            ));
2860        }
2861        if let Some((latest, median)) = t.slowdown() {
2862            let ratio = latest as f64 / median as f64;
2863            let color = if ratio >= 2.0 { p.err } else { p.warn };
2864            spans.push(Span::styled(
2865                format!(" ▲{ratio:.1}×"),
2866                Style::default().fg(color).add_modifier(Modifier::BOLD),
2867            ));
2868        }
2869        lines.push(Line::from(spans));
2870    }
2871
2872    let total = lines.len();
2873    let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
2874    f.render_widget(par, area);
2875    scrollbar(
2876        f,
2877        area,
2878        total,
2879        area.height.saturating_sub(2) as usize,
2880        rv.scroll as usize,
2881        p,
2882    );
2883}
2884
2885fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2886    let block = Block::default()
2887        .borders(Borders::ALL)
2888        .border_type(BorderType::Rounded)
2889        .border_style(Style::default().fg(p.border));
2890    let inner = block.inner(area);
2891    f.render_widget(block, area);
2892
2893    // Brand block: a two-tone brick mark, then DATABRICKS in the theme's
2894    // gradient with a bright shimmer sweeping across it while data loads,
2895    // then LAKEHOUSE letter-spaced in the accent color.
2896    let (from, to) = (p.grad_from, p.grad_to);
2897    let grad = |t: f32| {
2898        Color::Rgb(
2899            (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
2900            (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
2901            (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
2902        )
2903    };
2904    let mut left = vec![
2905        Span::styled(
2906            " ◢",
2907            Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
2908        ),
2909        Span::styled(
2910            "◤ ",
2911            Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
2912        ),
2913    ];
2914    let word: Vec<char> = "DATABRICKS".chars().collect();
2915    let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
2916    for (i, ch) in word.iter().enumerate() {
2917        let t = i as f32 / (word.len() - 1) as f32;
2918        let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
2919            Style::default().fg(p.text).add_modifier(Modifier::BOLD)
2920        } else {
2921            Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
2922        };
2923        left.push(Span::styled(ch.to_string(), style));
2924    }
2925    left.push(Span::raw(" "));
2926    for ch in "LAKEHOUSE".chars() {
2927        left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
2928    }
2929    left.push(Span::styled(
2930        format!("· v{}", env!("CARGO_PKG_VERSION")),
2931        Style::default().fg(p.dim),
2932    ));
2933    if let Some(profile) = &app.profile {
2934        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2935        left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
2936        left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
2937    }
2938    if let Some(Shape::Badge(b)) = &app.user_badge {
2939        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2940        left.push(Span::styled(
2941            format!("{} {}", b.label, b.value),
2942            Style::default().fg(p.key),
2943        ));
2944    }
2945    if app.zoomed {
2946        left.push(Span::styled("  ·  ", Style::default().fg(p.dim)));
2947        left.push(Span::styled(
2948            format!("⛶ {}", app.focus.title()),
2949            Style::default()
2950                .fg(accent(app.focus, p))
2951                .add_modifier(Modifier::BOLD),
2952        ));
2953    }
2954
2955    // Workspace health at a glance: counts of items per status bucket.
2956    let (ok, pending, failed, idle) = app.status_counts();
2957    for (count, glyph, color) in [
2958        (ok, "●", p.ok),
2959        (pending, "◐", p.warn),
2960        (failed, "✗", p.err),
2961        (idle, "○", p.dim),
2962    ] {
2963        if count > 0 {
2964            left.push(Span::styled("  ", Style::default()));
2965            left.push(Span::styled(
2966                format!("{glyph} {count}"),
2967                Style::default().fg(color),
2968            ));
2969        }
2970    }
2971    // Runs being watched for completion (W in a run view).
2972    if !app.watched.is_empty() {
2973        left.push(Span::styled("  ", Style::default()));
2974        left.push(Span::styled(
2975            format!("👁 {}", app.watched.len()),
2976            Style::default().fg(p.warn),
2977        ));
2978    }
2979    f.render_widget(Paragraph::new(Line::from(left)), inner);
2980
2981    let right = if app.loading {
2982        Line::from(Span::styled(
2983            format!("{} refreshing ", app.spinner()),
2984            Style::default().fg(p.warn),
2985        ))
2986    } else {
2987        Line::from(Span::styled(
2988            format!("updated {}s ago ", app.last_refresh_age().as_secs()),
2989            Style::default().fg(p.dim),
2990        ))
2991    };
2992    f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
2993}
2994
2995fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
2996    let key =
2997        |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
2998    let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
2999
3000    if let Some(confirm) = &app.confirm {
3001        let mut spans = vec![
3002            Span::styled(
3003                format!(" ⚠ {} ", confirm.message),
3004                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3005            ),
3006            key("y"),
3007            dim(" confirm · "),
3008        ];
3009        if confirm.params.is_some() {
3010            spans.push(key("p"));
3011            spans.push(dim(" edit parameters · "));
3012        }
3013        spans.push(dim("any other key cancels"));
3014        f.render_widget(Paragraph::new(Line::from(spans)), area);
3015        return;
3016    }
3017
3018    if app.filter_entry {
3019        let line = Line::from(vec![
3020            Span::styled(
3021                format!(" /{}", app.active_filter()),
3022                Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3023            ),
3024            Span::styled("▏", Style::default().fg(p.warn)),
3025            dim("  type to filter   "),
3026            key("enter"),
3027            dim(" apply   "),
3028            key("esc"),
3029            dim(" clear"),
3030        ]);
3031        f.render_widget(Paragraph::new(line), area);
3032        return;
3033    }
3034
3035    if let Some(pv) = &app.preview {
3036        if pv.filter_entry {
3037            let line = Line::from(vec![
3038                Span::styled(
3039                    format!(" cols /{}", pv.filter),
3040                    Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3041                ),
3042                Span::styled("▏", Style::default().fg(p.warn)),
3043                dim("  type to filter columns   "),
3044                key("enter"),
3045                dim(" apply   "),
3046                key("esc"),
3047                dim(" clear"),
3048            ]);
3049            f.render_widget(Paragraph::new(line), area);
3050            return;
3051        }
3052    }
3053
3054    let mut spans = if app.param_form.is_some() {
3055        vec![
3056            dim(" type key=value pairs   "),
3057            key("enter"),
3058            dim(" run   "),
3059            key("esc"),
3060            dim(" cancel"),
3061        ]
3062    } else if app.secret_form.is_some() {
3063        vec![
3064            dim(" type   "),
3065            key("enter"),
3066            dim(" next / save   "),
3067            key("esc"),
3068            dim(" cancel"),
3069        ]
3070    } else if app.help {
3071        vec![
3072            dim(" "),
3073            key("j"),
3074            dim("/"),
3075            key("k"),
3076            dim(" scroll   any other key closes"),
3077        ]
3078    } else if app.pane_cfg.is_some() {
3079        vec![
3080            dim(" "),
3081            key("j"),
3082            dim("/"),
3083            key("k"),
3084            dim(" select   "),
3085            key("space"),
3086            dim(" show/hide   "),
3087            key("J"),
3088            dim("/"),
3089            key("K"),
3090            dim(" move   "),
3091            key("esc"),
3092            dim(" done"),
3093        ]
3094    } else if app.jump.is_some() {
3095        vec![
3096            dim(" type to search everything   "),
3097            key("\u{2191}"),
3098            dim("/"),
3099            key("\u{2193}"),
3100            dim(" select   "),
3101            key("enter"),
3102            dim(" jump   "),
3103            key("esc"),
3104            dim(" close"),
3105        ]
3106    } else if app.problems.is_some() {
3107        vec![
3108            dim(" "),
3109            key("j"),
3110            dim("/"),
3111            key("k"),
3112            dim(" select   "),
3113            key("enter"),
3114            dim(" jump to item / switch workspace   "),
3115            key("esc"),
3116            dim(" close"),
3117        ]
3118    } else if app.upcoming.is_some() {
3119        vec![
3120            dim(" "),
3121            key("j"),
3122            dim("/"),
3123            key("k"),
3124            dim(" select   "),
3125            key("enter"),
3126            dim(" jump to job   "),
3127            key("esc"),
3128            dim(" close"),
3129        ]
3130    } else if app.cost.is_some() || app.item_cost.is_some() {
3131        vec![
3132            dim(" "),
3133            key("esc"),
3134            dim(" back   "),
3135            key("q"),
3136            dim(" quit"),
3137        ]
3138    } else if app.wh_picker.is_some() {
3139        vec![
3140            dim(" "),
3141            key("j"),
3142            dim("/"),
3143            key("k"),
3144            dim(" select   "),
3145            key("enter"),
3146            dim(" run preview   "),
3147            key("esc"),
3148            dim(" cancel"),
3149        ]
3150    } else if app.sql.is_some() {
3151        if app.hist_search.is_some() {
3152            vec![
3153                dim(" type to search history   "),
3154                key("^r"),
3155                dim(" older match   "),
3156                key("enter"),
3157                dim(" accept   "),
3158                key("esc"),
3159                dim(" cancel"),
3160            ]
3161        } else {
3162            let record = app.sql.as_ref().is_some_and(|c| c.record);
3163            // The toggle no-ops without rows, so it only earns footer
3164            // room — already tight — once there's something to transpose.
3165            let transposable = app
3166                .sql
3167                .as_ref()
3168                .is_some_and(|c| matches!(&c.data, Some(Ok(t)) if !t.rows.is_empty()));
3169            let mut spans = vec![
3170                dim(" "),
3171                key("enter"),
3172                dim(" run   "),
3173                key("tab"),
3174                dim(" complete   "),
3175                key("↑"),
3176                dim("/"),
3177                key("↓"),
3178                // The prompt owns these until the row is transposed, and
3179                // then they are the only scroll keys a laptop without a
3180                // pgdn of its own can reach without Fn.
3181                dim(if record { " fields   " } else { " history   " }),
3182                key("^r"),
3183                dim(" search   "),
3184                key("^x"),
3185                dim(" editor   "),
3186                key("pgup"),
3187                dim("/"),
3188                key("pgdn"),
3189                // Five at a time over the same fields ↑/↓ walk one by one.
3190                dim(if record { " page   " } else { " scroll   " }),
3191                key("⇧←→"),
3192                dim(if record { " rows   " } else { " cols   " }),
3193            ];
3194            if transposable {
3195                // f2 rather than the equivalent ^v: same two columns in a
3196                // footer with none to spare, and it is the one that works
3197                // everywhere — a terminal binding paste to ^v eats it, and
3198                // that includes WSL, where cfg!(windows) is false. `?`
3199                // carries both.
3200                spans.push(key("f2"));
3201                spans.push(dim(if record { " grid   " } else { " record   " }));
3202            }
3203            spans.extend([
3204                key("^s"),
3205                dim(" export   "),
3206                key("esc"),
3207                dim(if app.sql.as_ref().is_some_and(|c| c.running) {
3208                    " cancel query"
3209                } else if record {
3210                    " back to grid"
3211                } else {
3212                    " close"
3213                }),
3214            ]);
3215            spans
3216        }
3217    } else if let Some(pv) = &app.preview {
3218        if pv.record {
3219            vec![
3220                dim(" "),
3221                key("j"),
3222                dim("/"),
3223                key("k"),
3224                dim(" fields   "),
3225                key("h"),
3226                dim("/"),
3227                key("l"),
3228                dim(" prev/next row   "),
3229                key("v"),
3230                dim(" grid   "),
3231                key("/"),
3232                dim(" filter cols   "),
3233                key("esc"),
3234                dim(" back"),
3235            ]
3236        } else {
3237            vec![
3238                dim(" "),
3239                key("esc"),
3240                dim(" back   "),
3241                key("j"),
3242                dim("/"),
3243                key("k"),
3244                dim(" rows   "),
3245                key("←"),
3246                dim("/"),
3247                key("→"),
3248                dim(" columns   "),
3249                key("v"),
3250                dim(" row view   "),
3251                key("/"),
3252                dim(" filter cols   "),
3253                key("e"),
3254                dim(" export csv   "),
3255                key("q"),
3256                dim(" quit"),
3257            ]
3258        }
3259    } else if app.picker.is_some() {
3260        vec![
3261            dim(" "),
3262            key("j"),
3263            dim("/"),
3264            key("k"),
3265            dim(" select   "),
3266            key("enter"),
3267            dim(" switch workspace   "),
3268            key("esc"),
3269            dim(" cancel"),
3270        ]
3271    } else if let Some(rv) = &app.run_view {
3272        if rv.show_output {
3273            vec![
3274                dim(" "),
3275                key("esc"),
3276                dim(" back to run   "),
3277                key("j"),
3278                dim("/"),
3279                key("k"),
3280                dim(" scroll   "),
3281                key("q"),
3282                dim(" quit"),
3283            ]
3284        } else {
3285            let mut spans = vec![
3286                dim(" "),
3287                key("esc"),
3288                dim(" back   "),
3289                key("h"),
3290                dim("/"),
3291                key("l"),
3292                dim(" older/newer   "),
3293            ];
3294            if rv.panel == Panel::Jobs {
3295                spans.push(key("o"));
3296                spans.push(dim(" output   "));
3297                spans.push(key("t"));
3298                spans.push(dim(" timeline   "));
3299                spans.push(key("d"));
3300                spans.push(dim(" dag   "));
3301                spans.push(key("g"));
3302                spans.push(dim(" grid   "));
3303                spans.push(key("r"));
3304                spans.push(dim(" repair   "));
3305                spans.push(key("W"));
3306                spans.push(dim(
3307                    if app.watched.iter().any(|w| {
3308                        rv.runs
3309                            .get(rv.idx)
3310                            .is_some_and(|(id, _, _)| *id == w.run_id)
3311                    }) {
3312                        " unwatch   "
3313                    } else {
3314                        " watch   "
3315                    },
3316                ));
3317            }
3318            spans.extend([
3319                key("s"),
3320                dim(" cancel   "),
3321                key("j"),
3322                dim("/"),
3323                key("k"),
3324                dim(" scroll   "),
3325                key("J"),
3326                dim(" raw   "),
3327                key("q"),
3328                dim(" quit"),
3329            ]);
3330            spans
3331        }
3332    } else if app.detail.is_some() {
3333        let mut spans = vec![dim(" "), key("esc"), dim(" back   ")];
3334        match app.detail.as_ref() {
3335            Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
3336                spans.push(key("enter"));
3337                spans.push(dim(" latest run   "));
3338            }
3339            Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
3340                spans.push(key("enter"));
3341                spans.push(dim(" latest update   "));
3342            }
3343            _ => {}
3344        }
3345        spans.extend([
3346            key("j"),
3347            dim("/"),
3348            key("k"),
3349            dim(" scroll   "),
3350            key("J"),
3351            dim(" raw   "),
3352            key("o"),
3353            dim(" open   "),
3354            key("q"),
3355            dim(" quit"),
3356        ]);
3357        spans
3358    } else {
3359        let mut spans = vec![
3360            dim(" "),
3361            key("tab"),
3362            dim("/"),
3363            key("h"),
3364            dim("/"),
3365            key("l"),
3366            dim(" switch   "),
3367            key("j"),
3368            dim("/"),
3369            key("k"),
3370            dim(" select   "),
3371            key("/"),
3372            dim(if app.active_filter().is_empty() {
3373                " filter   "
3374            } else {
3375                " filter · esc clears   "
3376            }),
3377        ];
3378        // Enter means different things per pane — and nothing on a secret key.
3379        let enter_label = match app.focus {
3380            Panel::Secrets if app.secret_scope.is_some() => None,
3381            Panel::Secrets => Some(" open scope   "),
3382            Panel::Catalog if app.uc_path.len() < 2 => Some(" open   "),
3383            _ => Some(" details   "),
3384        };
3385        if let Some(label) = enter_label {
3386            spans.push(key("enter"));
3387            spans.push(dim(label));
3388        }
3389        // Only show keys that do something in the focused pane.
3390        match app.focus {
3391            Panel::Catalog => {
3392                if !app.uc_path.is_empty() {
3393                    spans.push(key("bksp"));
3394                    spans.push(dim(" up   "));
3395                }
3396                if app.uc_path.len() == 2 {
3397                    spans.push(key("p"));
3398                    spans.push(dim(" preview   "));
3399                    spans.push(key("L"));
3400                    spans.push(dim(" lineage   "));
3401                    spans.push(key(":"));
3402                    spans.push(dim(" query table   "));
3403                }
3404            }
3405            Panel::Dashboards => {}
3406            Panel::Secrets => {
3407                if app.secret_scope.is_some() {
3408                    spans.push(key("bksp"));
3409                    spans.push(dim(" up   "));
3410                }
3411                spans.push(key("a"));
3412                spans.push(dim(if app.secret_scope.is_some() {
3413                    " add secret   "
3414                } else {
3415                    " new scope   "
3416                }));
3417                spans.push(key("x"));
3418                spans.push(dim(" delete   "));
3419            }
3420            _ => {
3421                spans.push(key("s"));
3422                spans.push(dim(" action   "));
3423                if app.focus == Panel::Jobs {
3424                    spans.push(key("S"));
3425                    spans.push(dim(" pause   "));
3426                }
3427                if matches!(app.focus, Panel::Jobs | Panel::Pipelines) {
3428                    spans.push(key("c"));
3429                    spans.push(dim(" spend   "));
3430                }
3431            }
3432        }
3433        spans.extend([
3434            key("g"),
3435            dim(" access   "),
3436            key("$"),
3437            dim(" cost   "),
3438            key("!"),
3439            dim(" problems   "),
3440            key("u"),
3441            dim(" upcoming   "),
3442        ]);
3443        // At the catalog's table level the ':' hint already reads "query table".
3444        if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
3445            spans.push(key(":"));
3446            spans.push(dim(" sql   "));
3447        }
3448        spans.extend([
3449            key("o"),
3450            dim(" open   "),
3451            key("z"),
3452            dim(if app.zoomed { " unzoom   " } else { " zoom   " }),
3453            key("w"),
3454            dim(" workspace   "),
3455            key("t"),
3456            dim(" theme   "),
3457            key("r"),
3458            dim(" refresh   "),
3459            key("?"),
3460            dim(" help   "),
3461            key("q"),
3462            dim(" quit"),
3463        ]);
3464        spans
3465    };
3466    // A flash message prefixes the key hints instead of hiding them.
3467    if let Some((msg, _)) = &app.flash {
3468        let color = if msg.starts_with('✗') { p.err } else { p.ok };
3469        let mut prefixed = vec![
3470            Span::styled(
3471                format!(" {msg}"),
3472                Style::default().fg(color).add_modifier(Modifier::BOLD),
3473            ),
3474            dim("  ·  "),
3475        ];
3476        prefixed.append(&mut spans);
3477        spans = prefixed;
3478    }
3479    f.render_widget(Paragraph::new(Line::from(spans)), area);
3480}
3481
3482#[allow(clippy::too_many_arguments)]
3483fn draw_panel(
3484    f: &mut Frame,
3485    area: Rect,
3486    panel: Panel,
3487    shape: Option<&Shape>,
3488    focused: bool,
3489    selected: Option<usize>,
3490    fresh: bool,
3491    breadcrumb: &str,
3492    spinner: &str,
3493    filter: &str,
3494    entering: bool,
3495    favs: &std::collections::HashSet<String>,
3496    fav_only: bool,
3497    p: &Palette,
3498) {
3499    let accent = accent(panel, p);
3500    // Unfocused panes keep their status colors (the cross-pane signal)
3501    // but their text steps back so the active pane pops.
3502    let dimmed = |s: Style| {
3503        if focused {
3504            s
3505        } else {
3506            s.add_modifier(Modifier::DIM)
3507        }
3508    };
3509    let (border_style, title_style) = if focused {
3510        (
3511            Style::default().fg(accent).add_modifier(Modifier::BOLD),
3512            Style::default().fg(p.text).add_modifier(Modifier::BOLD),
3513        )
3514    } else {
3515        (Style::default().fg(p.border), Style::default().fg(p.dim))
3516    };
3517    let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
3518        Some(Shape::List(items)) => Some(
3519            items
3520                .iter()
3521                .filter(|it| {
3522                    crate::shape::item_matches(it, filter)
3523                        && (!fav_only || favs.contains(&crate::shape::fav_key(it)))
3524                })
3525                .collect(),
3526        ),
3527        _ => None,
3528    };
3529    let count = match (&visible, shape) {
3530        // With a filter active, show how many made the cut.
3531        (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
3532            format!(" · {}/{}", v.len(), items.len())
3533        }
3534        (Some(v), _) => format!(" · {}", v.len()),
3535        (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
3536        _ => String::new(),
3537    };
3538    let crumb = if !breadcrumb.is_empty() {
3539        format!(" ▸ {breadcrumb}")
3540    } else {
3541        String::new()
3542    };
3543    // A short reversed flash on the title when fresh data lands.
3544    let title_style = if fresh {
3545        Style::default()
3546            .bg(accent)
3547            .fg(Color::Black)
3548            .add_modifier(Modifier::BOLD)
3549    } else {
3550        title_style
3551    };
3552    let mut title_spans = vec![
3553        Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
3554        Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
3555    ];
3556    if entering || !filter.is_empty() {
3557        title_spans.push(Span::styled(
3558            format!("/{}{} ", filter, if entering { "▏" } else { "" }),
3559            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3560        ));
3561    }
3562    if fav_only {
3563        title_spans.push(Span::styled(
3564            "★only ",
3565            Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
3566        ));
3567    }
3568    let title = Line::from(title_spans);
3569    let block = Block::default()
3570        .title(title)
3571        .borders(Borders::ALL)
3572        .border_type(if focused {
3573            BorderType::Thick
3574        } else {
3575            BorderType::Rounded
3576        })
3577        .border_style(border_style)
3578        .padding(Padding::horizontal(1));
3579
3580    match shape {
3581        None => {
3582            let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
3583                .style(Style::default().fg(p.warn))
3584                .block(block);
3585            f.render_widget(par, area);
3586        }
3587        Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
3588            let empty = if !items.is_empty() {
3589                format!("∅ nothing matches /{filter}")
3590            } else {
3591                let msg = match panel {
3592                    Panel::Clusters => "no compute running",
3593                    Panel::Jobs => "no jobs defined",
3594                    Panel::Pipelines => "no pipelines",
3595                    Panel::Warehouses => "no warehouses",
3596                    Panel::Dashboards => "no dashboards yet",
3597                    Panel::Catalog => "nothing at this level",
3598                    Panel::Secrets => "no secret scopes",
3599                };
3600                format!("∅ {msg}")
3601            };
3602            let par = Paragraph::new(empty)
3603                .style(Style::default().fg(p.dim))
3604                .block(block);
3605            f.render_widget(par, area);
3606        }
3607        Some(Shape::List(_)) => {
3608            let list_items: Vec<ListItem> = visible
3609                .unwrap_or_default()
3610                .iter()
3611                .map(|item| {
3612                    let color = status_color(&item.status, p);
3613                    let chip = match item.status {
3614                        Status::Stopped | Status::Unknown(_) => Span::styled(
3615                            format!(" {} ", item.status.label()),
3616                            Style::default().fg(color).add_modifier(Modifier::DIM),
3617                        ),
3618                        _ => Span::styled(
3619                            format!(" {} ", item.status.label()),
3620                            Style::default()
3621                                .bg(color)
3622                                .fg(Color::Black)
3623                                .add_modifier(Modifier::BOLD),
3624                        ),
3625                    };
3626                    let star = if favs.contains(&crate::shape::fav_key(item)) {
3627                        Span::styled("★ ", Style::default().fg(p.warn))
3628                    } else {
3629                        Span::raw("  ")
3630                    };
3631                    let mut spans = vec![
3632                        star,
3633                        Span::styled("● ", Style::default().fg(color)),
3634                        Span::styled(item.name.as_str(), dimmed(Style::default().fg(p.text))),
3635                        Span::raw("  "),
3636                        chip,
3637                    ];
3638                    if !item.history.is_empty() {
3639                        spans.push(Span::raw("  "));
3640                        for run in &item.history {
3641                            spans.push(Span::styled(
3642                                history_glyph(run),
3643                                Style::default().fg(status_color(run, p)),
3644                            ));
3645                        }
3646                    }
3647                    if let Some(detail) = &item.detail {
3648                        // A running-long warning turns the detail amber.
3649                        let fg = if item.alert.is_some() { p.warn } else { p.dim };
3650                        spans.push(Span::styled(
3651                            format!("  {}", detail),
3652                            dimmed(Style::default().fg(fg)),
3653                        ));
3654                    }
3655                    ListItem::new(Line::from(spans))
3656                })
3657                .collect();
3658            let list = List::new(list_items)
3659                .block(block)
3660                .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
3661            match selected {
3662                Some(sel) => {
3663                    let mut state = ListState::default().with_selected(Some(sel));
3664                    f.render_stateful_widget(list, area, &mut state);
3665                }
3666                None => f.render_widget(list, area),
3667            }
3668        }
3669        Some(Shape::Table(data)) => {
3670            let header_cells: Vec<Cell> = data
3671                .headers
3672                .iter()
3673                .map(|h| {
3674                    Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
3675                })
3676                .collect();
3677            let header = Row::new(header_cells).style(Style::default().fg(accent));
3678            let rows: Vec<Row> = data
3679                .rows
3680                .iter()
3681                .map(|r| {
3682                    Row::new(
3683                        r.iter()
3684                            .map(|c| Cell::from(c.as_str()).style(dimmed(Style::default())))
3685                            .collect::<Vec<_>>(),
3686                    )
3687                })
3688                .collect();
3689            let widths: Vec<Constraint> = data
3690                .headers
3691                .iter()
3692                .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
3693                .collect();
3694            let table = Table::new(rows, widths).header(header).block(block);
3695            f.render_widget(table, area);
3696        }
3697        Some(Shape::Badge(b)) => {
3698            let text = format!("{}: {}", b.label, b.value);
3699            let par = Paragraph::new(text)
3700                .style(dimmed(Style::default().fg(p.text)))
3701                .block(block);
3702            f.render_widget(par, area);
3703        }
3704        Some(Shape::Text(t)) => {
3705            let color = if t.starts_with('✗') { p.err } else { p.text };
3706            let par = Paragraph::new(t.as_str())
3707                .style(dimmed(Style::default().fg(color)))
3708                .wrap(Wrap { trim: false })
3709                .block(block);
3710            f.render_widget(par, area);
3711        }
3712    }
3713}
3714
3715/// Vertical scrollbar on the right border of a bordered block when the
3716/// content overflows the viewport; `total` and `pos` are in rendered
3717/// lines (or rows).
3718fn scrollbar(f: &mut Frame, area: Rect, total: usize, viewport: usize, pos: usize, p: &Palette) {
3719    if viewport == 0 || total <= viewport {
3720        return;
3721    }
3722    let max = total - viewport;
3723    let mut state = ScrollbarState::new(max).position(pos.min(max));
3724    let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
3725        .begin_symbol(None)
3726        .end_symbol(None)
3727        .track_symbol(Some("│"))
3728        .track_style(Style::default().fg(p.border))
3729        .thumb_symbol("┃")
3730        .thumb_style(Style::default().fg(p.text));
3731    f.render_stateful_widget(
3732        sb,
3733        area.inner(Margin {
3734            horizontal: 0,
3735            vertical: 1,
3736        }),
3737        &mut state,
3738    );
3739}
3740
3741/// Rendered height of text wrapped at `width` columns.
3742fn wrapped_height(text: &str, width: usize) -> usize {
3743    let w = width.max(1);
3744    text.lines()
3745        .map(|l| l.chars().count().div_ceil(w).max(1))
3746        .sum()
3747}
3748
3749/// Words highlighted as keywords in the SQL prompt beyond the
3750/// autocomplete list.
3751const SQL_EXTRA_KEYWORDS: &[&str] = &[
3752    "TABLE", "VIEW", "IF", "EXISTS", "USE", "TRUE", "FALSE", "ASC", "DESC", "INTERVAL",
3753];
3754
3755fn is_sql_keyword(word: &str) -> bool {
3756    let up = word.to_ascii_uppercase();
3757    crate::app::SQL_KEYWORDS
3758        .iter()
3759        .any(|k| k.split_whitespace().any(|w| w == up))
3760        || SQL_EXTRA_KEYWORDS.contains(&up.as_str())
3761}
3762
3763/// SQL tokenized into styled spans for the console prompt: keywords,
3764/// strings, quoted identifiers, numbers and comments each get a color.
3765/// Char counts are preserved so the caret and hscroll math hold.
3766fn sql_spans(sql: &str, p: &Palette) -> Vec<Span<'static>> {
3767    let chars: Vec<char> = sql.chars().collect();
3768    let mut spans = Vec::new();
3769    let mut i = 0;
3770    let push = |spans: &mut Vec<Span<'static>>, text: String, style: Style| {
3771        if !text.is_empty() {
3772            spans.push(Span::styled(text, style));
3773        }
3774    };
3775    while i < chars.len() {
3776        let c = chars[i];
3777        if c == '\'' || c == '`' || c == '"' {
3778            // String or quoted identifier — runs to the closing quote,
3779            // or to the end while still being typed.
3780            let start = i;
3781            i += 1;
3782            while i < chars.len() && chars[i] != c {
3783                i += 1;
3784            }
3785            if i < chars.len() {
3786                i += 1;
3787            }
3788            let color = if c == '\'' { p.ok } else { p.catalog };
3789            push(
3790                &mut spans,
3791                chars[start..i].iter().collect(),
3792                Style::default().fg(color),
3793            );
3794        } else if c == '-' && chars.get(i + 1) == Some(&'-') {
3795            push(
3796                &mut spans,
3797                chars[i..].iter().collect(),
3798                Style::default().fg(p.dim),
3799            );
3800            break;
3801        } else if c.is_ascii_digit() {
3802            let start = i;
3803            while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '.') {
3804                i += 1;
3805            }
3806            push(
3807                &mut spans,
3808                chars[start..i].iter().collect(),
3809                Style::default().fg(p.warn),
3810            );
3811        } else if c.is_alphanumeric() || c == '_' {
3812            let start = i;
3813            while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') {
3814                i += 1;
3815            }
3816            let word: String = chars[start..i].iter().collect();
3817            let style = if is_sql_keyword(&word) {
3818                Style::default().fg(p.key).add_modifier(Modifier::BOLD)
3819            } else {
3820                Style::default().fg(p.text)
3821            };
3822            push(&mut spans, word, style);
3823        } else {
3824            let start = i;
3825            i += 1;
3826            while i < chars.len() {
3827                let c = chars[i];
3828                if c.is_alphanumeric() || matches!(c, '_' | '\'' | '`' | '"' | '-') {
3829                    break;
3830                }
3831                i += 1;
3832            }
3833            push(
3834                &mut spans,
3835                chars[start..i].iter().collect(),
3836                Style::default().fg(p.text),
3837            );
3838        }
3839    }
3840    spans
3841}
3842
3843/// One rendered line of task output: section headers stand out, error
3844/// and warning lines are tinted, leading timestamps are dimmed so the
3845/// message carries the color.
3846fn output_line<'a>(line: &'a str, p: &Palette) -> Line<'a> {
3847    if let Some(rest) = line.strip_prefix("── ") {
3848        let color = if rest.contains("FAILED") {
3849            p.err
3850        } else if rest.contains("RUNNING") || rest.contains("PENDING") {
3851            p.warn
3852        } else if rest.contains("SUCCESS") {
3853            p.ok
3854        } else {
3855            p.text
3856        };
3857        return Line::from(Span::styled(
3858            line,
3859            Style::default().fg(color).add_modifier(Modifier::BOLD),
3860        ));
3861    }
3862    let trimmed = line.trim_start();
3863    let indented = line.starts_with(char::is_whitespace);
3864    let body = if line.contains("ERROR")
3865        || line.contains("FATAL")
3866        || line.contains("Exception")
3867        || trimmed.starts_with("Traceback")
3868        || trimmed.starts_with("Caused by")
3869        || (indented && (trimmed.starts_with("at ") || trimmed.starts_with("File \"")))
3870    {
3871        Style::default().fg(p.err)
3872    } else if line.contains("WARN") {
3873        Style::default().fg(p.warn)
3874    } else if line == "logs (tail):" || trimmed.starts_with("(no output") {
3875        Style::default().fg(p.dim)
3876    } else {
3877        Style::default().fg(p.text)
3878    };
3879    let ts = timestamp_prefix(line);
3880    if ts > 0 {
3881        Line::from(vec![
3882            Span::styled(&line[..ts], Style::default().fg(p.dim)),
3883            Span::styled(&line[ts..], body),
3884        ])
3885    } else {
3886        Line::from(Span::styled(line, body))
3887    }
3888}
3889
3890/// Byte length of a leading timestamp-ish prefix ("26/07/18 12:34:56 ",
3891/// ISO dates), 0 when there is none. Requires enough digits that plain
3892/// leading numbers don't get dimmed.
3893fn timestamp_prefix(line: &str) -> usize {
3894    let mut len = 0;
3895    let mut digits = 0;
3896    for (i, c) in line.char_indices() {
3897        if c.is_ascii_digit() {
3898            digits += 1;
3899        } else if !matches!(c, '/' | '-' | ':' | '.' | ',' | 'T' | 'Z' | ' ') {
3900            break;
3901        }
3902        len = i + c.len_utf8();
3903        if len >= 30 {
3904            break;
3905        }
3906    }
3907    if digits >= 6 && len >= 8 {
3908        len
3909    } else {
3910        0
3911    }
3912}
3913
3914fn status_color(status: &Status, p: &Palette) -> Color {
3915    match status {
3916        Status::Running | Status::Success => p.ok,
3917        Status::Stopped => p.dim,
3918        Status::Pending => p.warn,
3919        Status::Failed => p.err,
3920        Status::Unknown(_) => p.text,
3921    }
3922}
3923
3924fn history_glyph(status: &Status) -> &'static str {
3925    match status {
3926        Status::Success => "✓",
3927        Status::Failed => "✗",
3928        Status::Running => "●",
3929        Status::Pending => "◐",
3930        Status::Stopped => "○",
3931        Status::Unknown(_) => "·",
3932    }
3933}
3934
3935#[cfg(test)]
3936mod tests {
3937    use super::*;
3938
3939    fn find<'a>(spans: &'a [Span], text: &str) -> &'a Span<'a> {
3940        spans
3941            .iter()
3942            .find(|s| s.content == text)
3943            .unwrap_or_else(|| panic!("no span {text:?} in {spans:?}"))
3944    }
3945
3946    #[test]
3947    fn sql_spans_color_keywords_strings_numbers_comments() {
3948        let p = palette(ThemeMode::Dark);
3949        let spans = sql_spans("SELECT count(x) FROM t WHERE s = 'bob' LIMIT 10 -- hi", &p);
3950        assert_eq!(find(&spans, "SELECT").style.fg, Some(p.key));
3951        assert!(find(&spans, "SELECT")
3952            .style
3953            .add_modifier
3954            .contains(Modifier::BOLD));
3955        assert_eq!(find(&spans, "count").style.fg, Some(p.key));
3956        assert_eq!(find(&spans, "t").style.fg, Some(p.text));
3957        assert_eq!(find(&spans, "'bob'").style.fg, Some(p.ok));
3958        assert_eq!(find(&spans, "10").style.fg, Some(p.warn));
3959        assert_eq!(find(&spans, "-- hi").style.fg, Some(p.dim));
3960    }
3961
3962    #[test]
3963    fn sql_spans_preserve_every_char() {
3964        let p = palette(ThemeMode::Dark);
3965        for sql in [
3966            "SELECT `col a`, b - 1 FROM main.sales.orders WHERE s = 'unclosed",
3967            "-- only a comment",
3968            "  \"quoted id\" <> 3.5e2 ",
3969        ] {
3970            let joined: String = sql_spans(sql, &p)
3971                .iter()
3972                .map(|s| s.content.as_ref())
3973                .collect();
3974            assert_eq!(joined, sql);
3975        }
3976    }
3977
3978    #[test]
3979    fn spark_scales_min_to_max() {
3980        assert_eq!(spark(&[1, 1, 1]), "▄▄▄");
3981        assert_eq!(spark(&[0, 7]), "▁█");
3982        assert_eq!(spark(&[100, 150, 200]), "▁▄█");
3983        assert_eq!(spark(&[]), "");
3984    }
3985
3986    /// Renders one frame and returns it as plain text lines. Wide enough
3987    /// that assertions are about content, not the footer clipping.
3988    fn render_at(app: &App, width: u16) -> Vec<String> {
3989        let backend = ratatui::backend::TestBackend::new(width, 30);
3990        let mut terminal = ratatui::Terminal::new(backend).unwrap();
3991        terminal.draw(|f| draw(f, app)).unwrap();
3992        let buffer = terminal.backend().buffer().clone();
3993        (0..buffer.area.height)
3994            .map(|y| {
3995                (0..buffer.area.width)
3996                    .map(|x| buffer[(x, y)].symbol())
3997                    .collect::<String>()
3998                    .trim_end()
3999                    .to_string()
4000            })
4001            .collect()
4002    }
4003
4004    fn render(app: &App) -> Vec<String> {
4005        render_at(app, 100)
4006    }
4007
4008    fn item_cost_app(data: fetchers::cost::ResourceCost) -> App {
4009        let mut app = App::new(60, ThemeMode::Dark);
4010        app.dismiss_splash();
4011        app.item_cost = Some(crate::app::ItemCostView {
4012            warehouse: "Starter Warehouse".to_string(),
4013            kind: data.kind,
4014            name: "nightly-etl".to_string(),
4015            id: "42".to_string(),
4016            data: Some(Ok(data)),
4017        });
4018        app
4019    }
4020
4021    fn window(
4022        label: &'static str,
4023        days: i64,
4024        dbus: f64,
4025        usd: f64,
4026        prior: Option<(f64, f64)>,
4027    ) -> fetchers::cost::CostWindow {
4028        fetchers::cost::CostWindow {
4029            label,
4030            days,
4031            dbus,
4032            usd,
4033            prior,
4034        }
4035    }
4036
4037    #[test]
4038    fn item_cost_view_shows_every_window_with_its_trend() {
4039        let data = fetchers::cost::ResourceCost {
4040            kind: fetchers::cost::ResourceKind::Job,
4041            windows: vec![
4042                window("last week", 7, 12.4, 9.61, Some((10.0, 8.14))),
4043                window("last month", 30, 58.2, 45.1, Some((60.0, 47.0))),
4044                window("last quarter", 90, 140.9, 109.22, Some((0.0, 0.0))),
4045                window("last year", 365, 402.3, 311.8, None),
4046            ],
4047            months: vec![
4048                ("2026-06".to_string(), 40.1, 31.02),
4049                ("2026-07".to_string(), 58.2, 45.1),
4050            ],
4051            priced: true,
4052            scoped: true,
4053        };
4054        let text = render(&item_cost_app(data)).join("\n");
4055
4056        assert!(
4057            text.contains("Spend · nightly-etl · job · this workspace"),
4058            "{text}"
4059        );
4060        for label in ["last week", "last month", "last quarter", "last year"] {
4061            assert!(text.contains(label), "missing {label} in {text}");
4062        }
4063        assert!(text.contains("$9.61"), "{text}");
4064        // Rising against the prior window, falling, no prior usage, no
4065        // prior window at all.
4066        assert!(text.contains("▲ 18%"), "{text}");
4067        assert!(text.contains("▼ 4%"), "{text}");
4068        assert!(text.contains("BY MONTH"), "{text}");
4069        assert!(text.contains("2026-06"), "{text}");
4070    }
4071
4072    fn sql_app(record: bool) -> App {
4073        let mut app = App::new(60, ThemeMode::Dark);
4074        app.dismiss_splash();
4075        app.sql = Some(crate::app::SqlConsole {
4076            input: "SELECT * FROM main.sales.orders LIMIT 50".to_string(),
4077            cursor: 0,
4078            warehouse: "Starter Warehouse".to_string(),
4079            running: false,
4080            data: Some(Ok(TableData {
4081                headers: vec![
4082                    "order_id".to_string(),
4083                    "customer".to_string(),
4084                    "shipped_at".to_string(),
4085                ],
4086                rows: vec![
4087                    vec!["1001".to_string(), "acme".to_string(), "␀".to_string()],
4088                    vec![
4089                        "1002".to_string(),
4090                        "globex".to_string(),
4091                        "2026-08-01".to_string(),
4092                    ],
4093                ],
4094            })),
4095            last_sql: "SELECT * FROM main.sales.orders LIMIT 50".to_string(),
4096            scroll: 1,
4097            col: 0,
4098            record,
4099            rscroll: 0,
4100        });
4101        app
4102    }
4103
4104    #[test]
4105    fn sql_record_view_stacks_one_row_and_marks_which() {
4106        let text = render_at(&sql_app(true), 140).join("\n");
4107
4108        // The selected row, one field per line — not the grid header.
4109        assert!(text.contains("row 2/2"), "{text}");
4110        assert!(text.contains("order_id    1002"), "{text}");
4111        assert!(text.contains("customer    globex"), "{text}");
4112        assert!(text.contains("shipped_at  2026-08-01"), "{text}");
4113        // The footer relabels the keys it shares with the grid.
4114        assert!(text.contains("fields"), "{text}");
4115        assert!(text.contains("f2 grid"), "{text}");
4116    }
4117
4118    #[test]
4119    fn sql_arrows_walk_fields_transposed_and_history_at_the_prompt() {
4120        // Transposed, ↓ steps one field, so the stack starts a field lower.
4121        let mut app = sql_app(true);
4122        app.sql_vertical(1);
4123        let text = render_at(&app, 140).join("\n");
4124        assert!(text.contains("customer    globex"), "{text}");
4125        assert!(!text.contains("order_id    1002"), "{text}");
4126        assert!(text.contains("↑/↓ fields"), "{text}");
4127        // One field at a time, where pgup/pgdn takes five.
4128        assert!(text.contains("pgup/pgdn page"), "{text}");
4129
4130        // ↑ walks back up, and stops at the first field.
4131        app.sql_vertical(-1);
4132        app.sql_vertical(-1);
4133        let text = render_at(&app, 140).join("\n");
4134        assert!(text.contains("order_id    1002"), "{text}");
4135
4136        // At the prompt they still belong to history, results untouched.
4137        let mut grid = sql_app(false);
4138        grid.sql_vertical(1);
4139        let text = render_at(&grid, 140).join("\n");
4140        assert!(text.contains("↑/↓ history"), "{text}");
4141        assert!(text.contains("order_id"), "{text}");
4142    }
4143
4144    #[test]
4145    fn sql_grid_view_is_unchanged_and_offers_the_record_toggle() {
4146        let text = render_at(&sql_app(false), 140).join("\n");
4147        // Grid keeps its header row and column labels.
4148        assert!(text.contains("order_id"), "{text}");
4149        assert!(text.contains("customer"), "{text}");
4150        assert!(!text.contains("row 2/2"), "{text}");
4151        assert!(text.contains("f2 record"), "{text}");
4152    }
4153
4154    #[test]
4155    fn item_cost_view_explains_an_unattributed_job() {
4156        let data = fetchers::cost::ResourceCost {
4157            kind: fetchers::cost::ResourceKind::Job,
4158            windows: Vec::new(),
4159            months: Vec::new(),
4160            priced: true,
4161            scoped: true,
4162        };
4163        let text = render(&item_cost_app(data)).join("\n");
4164        assert!(text.contains("no usage attributed to this job"), "{text}");
4165        assert!(text.contains("all-purpose compute"), "{text}");
4166    }
4167}