zagens-cli 0.8.3

Zagens headless CLI + HTTP/SSE runtime sidecar (`zagens`, `zagens-runtime` binaries)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
//! Three-column layout constraints and persisted fold state.

use std::fs;
use std::path::PathBuf;

use ratatui::layout::{Constraint, Direction, Layout, Rect};
use serde::{Deserialize, Serialize};

use super::focus::FocusRegion;
use super::theme;

const MIN_CENTER_COLS: u16 = 40;
const LEFT_MAX: u16 = 32;
const LEFT_MIN: u16 = 24;
const RIGHT_MAX: u16 = 40;
const RIGHT_MIN: u16 = 20;
/// User-resizable upper bound (still capped by terminal width − center min).
const RIGHT_ABS_MAX: u16 = 72;
/// Columns added/removed per `-` / `=` press while the right rail is focused.
pub const RIGHT_WIDTH_STEP: i16 = 2;
const LEFT_RATIO: f32 = 0.22;
const RIGHT_RATIO: f32 = 0.30;

/// Status chips row inside the Composer block (model, mode, task type, …).
pub const COMPOSER_FOOTER_ROWS: u16 = 1;

/// Faint horizontal rules above and below the composer footer chips.
pub const COMPOSER_FOOTER_DIVIDER_ROWS: u16 = 1;

/// Blank columns inset from center column side borders before text (each side).
pub const CENTER_CONTENT_PAD: u16 = 1;

/// Black separator column between panels in borderless themes (avoids seam bleed on Windows).
pub const BORDERLESS_GUTTER_COLS: u16 = 1;

/// Minimum rows for the upper inspector pane when LHT lower pane is visible.
pub const RIGHT_INSPECTOR_MIN_ROWS: u16 = 5;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InspectorTab {
    Files,
    Diff,
    Agents,
    Mcp,
    Activity,
}

impl InspectorTab {
    pub const ALL: [Self; 5] = [
        Self::Files,
        Self::Diff,
        Self::Agents,
        Self::Mcp,
        Self::Activity,
    ];

    pub fn label(self) -> &'static str {
        match self {
            Self::Files => "Files",
            Self::Diff => "Diff",
            Self::Agents => "Agents",
            Self::Mcp => "MCP",
            Self::Activity => "Activity",
        }
    }

    pub fn from_index(n: u8) -> Option<Self> {
        match n {
            1 => Some(Self::Files),
            2 => Some(Self::Diff),
            3 => Some(Self::Agents),
            4 => Some(Self::Mcp),
            5 => Some(Self::Activity),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TuiLayoutPrefs {
    #[serde(default)]
    pub left_collapsed: bool,
    #[serde(default)]
    pub right_collapsed: bool,
    #[serde(default = "default_inspector")]
    pub active_inspector: String,
    /// Last focused thread in TUI; restored on launch (unless `--fresh`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_thread_id: Option<String>,
    /// Right rail width in terminal columns (`None` = 30% default).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub right_width: Option<u16>,
    /// TUI surface theme (`cool-blue`, `gray-scale`, `classic`, …). Default: cool-blue.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tui_theme: Option<String>,
}

fn default_inspector() -> String {
    "files".to_string()
}

impl Default for TuiLayoutPrefs {
    fn default() -> Self {
        Self {
            left_collapsed: false,
            right_collapsed: false,
            active_inspector: default_inspector(),
            last_thread_id: None,
            right_width: None,
            tui_theme: None,
        }
    }
}

impl TuiLayoutPrefs {
    pub fn path() -> Option<PathBuf> {
        zagens_config::user_data_path("tui-layout.toml").ok()
    }

    pub fn load() -> Self {
        let Some(path) = Self::path() else {
            return Self::default();
        };
        let Ok(raw) = fs::read_to_string(&path) else {
            return Self::default();
        };
        toml::from_str(&raw).unwrap_or_default()
    }

    pub fn save(&self) -> std::io::Result<()> {
        let Some(path) = Self::path() else {
            return Ok(());
        };
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        let body = toml::to_string_pretty(self).map_err(std::io::Error::other)?;
        fs::write(path, body)
    }

    pub fn inspector_tab(&self) -> InspectorTab {
        match self.active_inspector.as_str() {
            "diff" => InspectorTab::Diff,
            "agents" => InspectorTab::Agents,
            "mcp" => InspectorTab::Mcp,
            "activity" => InspectorTab::Activity,
            "checklist" => InspectorTab::Files,
            _ => InspectorTab::Files,
        }
    }

    pub fn set_inspector_tab(&mut self, tab: InspectorTab) {
        self.active_inspector = match tab {
            InspectorTab::Files => "files",
            InspectorTab::Diff => "diff",
            InspectorTab::Agents => "agents",
            InspectorTab::Mcp => "mcp",
            InspectorTab::Activity => "activity",
        }
        .to_string();
    }
}

#[derive(Debug, Clone)]
pub struct RightPaneRegions {
    pub inspector: Rect,
    pub lht: Rect,
    pub lht_visible: bool,
}

#[derive(Debug, Clone)]
pub struct LayoutRegions {
    pub title: Rect,
    pub left: Rect,
    pub center: Rect,
    pub right: Rect,
    /// 1-col black gutter between left and center (borderless only).
    pub gutter_left: Rect,
    /// 1-col black gutter between center and right (borderless only).
    pub gutter_right: Rect,
    pub left_visible: bool,
    pub right_visible: bool,
}

/// Vertical stack inside the center column (transcript → activity? → composer → status).
#[derive(Debug, Clone)]
pub struct CenterColumnRegions {
    pub transcript: Rect,
    pub activity: Option<Rect>,
    pub composer: Rect,
    pub status: Rect,
}

pub struct LayoutEngine {
    pub prefs: TuiLayoutPrefs,
    pub inline_mode: bool,
    pub focus: FocusRegion,
    pub composer_lines: u16,
    /// Updated each frame for width adjustment clamping.
    pub last_terminal_width: u16,
}

impl LayoutEngine {
    pub fn new(inline_mode: bool, prefs: TuiLayoutPrefs) -> Self {
        Self {
            prefs,
            inline_mode,
            focus: FocusRegion::Chat,
            composer_lines: if inline_mode { 6 } else { 9 },
            last_terminal_width: 120,
        }
    }

    /// Narrow/widen the right rail when focused (`-` / `=`). Persists via `prefs.right_width`.
    pub fn adjust_right_width(&mut self, delta: i16) -> bool {
        if !self.right_rail_available() {
            return false;
        }
        let delta = if delta < 0 {
            -RIGHT_WIDTH_STEP
        } else {
            RIGHT_WIDTH_STEP
        };
        let total = self
            .last_terminal_width
            .max(MIN_CENTER_COLS + RIGHT_MIN + 4);
        let left_w = self.effective_left_width(total);
        let current = self.effective_right_width(total, left_w);
        let bounds = right_width_bounds(total, left_w, self.left_rail_available());
        let next = (i32::from(current) + i32::from(delta))
            .clamp(i32::from(RIGHT_MIN), i32::from(bounds)) as u16;
        if next == current {
            return false;
        }
        self.prefs.right_width = Some(next);
        true
    }

    fn effective_left_width(&self, total: u16) -> u16 {
        if !self.left_rail_available() {
            return 0;
        }
        column_width(total, LEFT_RATIO, LEFT_MIN, LEFT_MAX)
    }

    fn effective_right_width(&self, total: u16, left_w: u16) -> u16 {
        if !self.right_rail_available() {
            return 0;
        }
        let bounds = right_width_bounds(total, left_w, self.left_rail_available());
        if let Some(stored) = self.prefs.right_width {
            return stored.clamp(RIGHT_MIN, bounds);
        }
        column_width(total, RIGHT_RATIO, RIGHT_MIN, RIGHT_MAX.min(bounds))
    }

    /// Pixel column width of the left rail (0 when collapsed / inline).
    pub fn left_width(&self) -> u16 {
        self.effective_left_width(self.last_terminal_width)
    }

    /// Pixel column width of the right rail (0 when collapsed / inline).
    pub fn right_width(&self) -> u16 {
        let left_w = self.effective_left_width(self.last_terminal_width);
        self.effective_right_width(self.last_terminal_width, left_w)
    }

    pub fn toggle_left(&mut self) {
        self.prefs.left_collapsed = !self.prefs.left_collapsed;
    }

    pub fn toggle_right(&mut self) {
        self.prefs.right_collapsed = !self.prefs.right_collapsed;
    }

    pub fn left_rail_available(&self) -> bool {
        !self.inline_mode && !self.prefs.left_collapsed
    }

    pub fn right_rail_available(&self) -> bool {
        !self.inline_mode && !self.prefs.right_collapsed
    }

    pub fn focus_next_visible(&self) -> FocusRegion {
        self.step_focus_visible(true)
    }

    pub fn focus_prev_visible(&self) -> FocusRegion {
        self.step_focus_visible(false)
    }

    fn step_focus_visible(&self, forward: bool) -> FocusRegion {
        let mut region = self.focus;
        for _ in 0..3 {
            region = if forward {
                region.next()
            } else {
                region.prev()
            };
            if self.is_focus_region_visible(region) {
                return region;
            }
        }
        FocusRegion::Chat
    }

    fn is_focus_region_visible(&self, region: FocusRegion) -> bool {
        match region {
            FocusRegion::Left => self.left_rail_available(),
            FocusRegion::Chat => true,
            FocusRegion::Right => self.right_rail_available(),
        }
    }

    pub fn apply_auto_collapse(&mut self, width: u16) {
        if self.inline_mode {
            self.prefs.left_collapsed = true;
            self.prefs.right_collapsed = true;
            return;
        }
        if width < 100 {
            self.prefs.left_collapsed = true;
            self.prefs.right_collapsed = true;
        } else if width < 120 {
            self.prefs.right_collapsed = true;
        }
    }

    pub fn regions(&self, area: Rect) -> LayoutRegions {
        let title_rows = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(1), Constraint::Min(0)])
            .split(area);
        let body = title_rows[1];

        if self.inline_mode {
            return LayoutRegions {
                title: title_rows[0],
                left: Rect::default(),
                center: body,
                right: Rect::default(),
                gutter_left: Rect::default(),
                gutter_right: Rect::default(),
                left_visible: false,
                right_visible: false,
            };
        }

        let left_visible = !self.prefs.left_collapsed;
        let right_visible = !self.prefs.right_collapsed;

        let left_w = if left_visible {
            self.effective_left_width(area.width)
        } else {
            0
        };
        let right_w = if right_visible {
            self.effective_right_width(area.width, left_w)
        } else {
            0
        };
        let borderless = theme::current().borderless();
        let gutter = if borderless {
            BORDERLESS_GUTTER_COLS
        } else {
            0
        };
        let gutter_total = if borderless {
            (u16::from(left_visible) + u16::from(right_visible)) * gutter
        } else {
            0
        };
        let center_w = area
            .width
            .saturating_sub(left_w)
            .saturating_sub(right_w)
            .saturating_sub(gutter_total)
            .saturating_sub(4);

        let (left_w, right_w, left_visible, right_visible) = if center_w < MIN_CENTER_COLS {
            if left_visible && right_visible && area.width >= 100 {
                (left_w, 0, true, false)
            } else {
                (0, 0, false, false)
            }
        } else {
            (left_w, right_w, left_visible, right_visible)
        };

        let mut h_constraints = Vec::new();
        if left_visible {
            h_constraints.push(Constraint::Length(left_w));
            if gutter > 0 {
                h_constraints.push(Constraint::Length(gutter));
            }
        }
        h_constraints.push(Constraint::Min(MIN_CENTER_COLS));
        if right_visible {
            if gutter > 0 {
                h_constraints.push(Constraint::Length(gutter));
            }
            h_constraints.push(Constraint::Length(right_w));
        }

        let cols = Layout::default()
            .direction(Direction::Horizontal)
            .constraints(h_constraints)
            .split(body);

        let mut idx = 0usize;
        let left = if left_visible {
            let r = cols[idx];
            idx += 1;
            r
        } else {
            Rect::default()
        };
        let gutter_left = if left_visible && gutter > 0 {
            let r = cols[idx];
            idx += 1;
            r
        } else {
            Rect::default()
        };
        let center = {
            let r = cols[idx];
            idx += 1;
            r
        };
        let gutter_right = if right_visible && gutter > 0 {
            let r = cols[idx];
            idx += 1;
            r
        } else {
            Rect::default()
        };
        let right = if right_visible {
            cols.get(idx).copied().unwrap_or_default()
        } else {
            Rect::default()
        };

        LayoutRegions {
            title: title_rows[0],
            left,
            center,
            right,
            gutter_left,
            gutter_right,
            left_visible,
            right_visible,
        }
    }

    /// Split the right rail into upper inspector + optional lower LHT pane.
    pub fn split_right_pane(&self, right: Rect, lht_visible: bool) -> RightPaneRegions {
        // In borderless mode, reserve 1 row as a visual gap before the LHT title so it
        // doesn't crowd the inspector content directly above it.
        let gap: u16 = if theme::current().borderless() { 1 } else { 0 };
        let min_height = RIGHT_INSPECTOR_MIN_ROWS + 6 + gap;
        if !lht_visible || right.height < min_height {
            return RightPaneRegions {
                inspector: right,
                lht: Rect::default(),
                lht_visible: false,
            };
        }
        let rows = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(RIGHT_INSPECTOR_MIN_ROWS),
                Constraint::Min(6 + gap),
            ])
            .split(right);
        // Shift the LHT area down by `gap` rows; that top gap row retains root bg and
        // acts as a blank separator between the two sub-panes.
        let lht = if gap > 0 {
            Rect {
                y: rows[1].y + gap,
                height: rows[1].height.saturating_sub(gap),
                ..rows[1]
            }
        } else {
            rows[1]
        };
        RightPaneRegions {
            inspector: rows[0],
            lht,
            lht_visible: true,
        }
    }
}

/// Split the center column into transcript, optional activity strip, composer, and status.
pub fn split_center_column(
    area: Rect,
    live_activity: bool,
    composer_lines: u16,
) -> CenterColumnRegions {
    let chrome_rows = super::theme::pane_chrome_rows();
    let footer_rows = COMPOSER_FOOTER_ROWS.saturating_add(chrome_rows);
    let activity_rows = if live_activity {
        1_u16.saturating_add(chrome_rows)
    } else {
        0
    };
    let input_rows = composer_lines
        .saturating_sub(footer_rows)
        .saturating_sub(activity_rows)
        .max(chrome_rows.saturating_add(1));

    let mut constraints: Vec<Constraint> = vec![Constraint::Min(8)];
    if live_activity {
        constraints.push(Constraint::Length(activity_rows));
    }
    constraints.extend([
        Constraint::Length(input_rows),
        Constraint::Length(footer_rows),
    ]);
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(area);

    let mut idx = 0usize;
    let transcript = rows[idx];
    idx += 1;
    let activity = if live_activity {
        let act = rows[idx];
        idx += 1;
        Some(act)
    } else {
        None
    };
    let composer = rows[idx];
    idx += 1;
    let status = rows[idx];

    CenterColumnRegions {
        transcript,
        activity,
        composer,
        status,
    }
}

fn column_width(total: u16, ratio: f32, min: u16, max: u16) -> u16 {
    let w = ((total as f32) * ratio).round() as u16;
    w.clamp(min, max)
        .min(total.saturating_sub(MIN_CENTER_COLS + 4))
}

fn right_width_bounds(total: u16, left_w: u16, left_visible: bool) -> u16 {
    let left = if left_visible { left_w } else { 0 };
    total
        .saturating_sub(MIN_CENTER_COLS)
        .saturating_sub(left)
        .saturating_sub(2)
        .clamp(RIGHT_MIN, RIGHT_ABS_MAX)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn narrow_width_hides_sidebars() {
        let engine = LayoutEngine::new(false, TuiLayoutPrefs::default());
        let regions = engine.regions(Rect::new(0, 0, 80, 24));
        assert!(!regions.left_visible);
        assert!(!regions.right_visible);
        assert!(regions.center.width >= MIN_CENTER_COLS);
    }

    #[test]
    fn focus_next_skips_collapsed_sidebars() {
        let mut engine = LayoutEngine::new(false, TuiLayoutPrefs::default());
        engine.prefs.left_collapsed = true;
        engine.prefs.right_collapsed = true;
        engine.focus = FocusRegion::Chat;
        assert_eq!(engine.focus_next_visible(), FocusRegion::Chat);
        engine.prefs.right_collapsed = false;
        assert_eq!(engine.focus_next_visible(), FocusRegion::Right);
    }

    #[test]
    fn wide_width_shows_three_columns() {
        let engine = LayoutEngine::new(false, TuiLayoutPrefs::default());
        let regions = engine.regions(Rect::new(0, 0, 140, 40));
        assert!(regions.left_visible);
        assert!(regions.right_visible);
        assert!(regions.center.width >= MIN_CENTER_COLS);
    }

    #[test]
    fn split_right_pane_reserves_lht_section() {
        let engine = LayoutEngine::new(false, TuiLayoutPrefs::default());
        let right = Rect::new(0, 0, 32, 30);
        let split = engine.split_right_pane(right, true);
        assert!(split.lht_visible);
        assert!(split.inspector.height + split.lht.height <= right.height);
    }

    #[test]
    fn adjust_right_width_persists_and_clamps() {
        let mut engine = LayoutEngine::new(false, TuiLayoutPrefs::default());
        engine.last_terminal_width = 160;
        assert!(engine.adjust_right_width(1));
        assert_eq!(engine.prefs.right_width, Some(42));
        let bounds = right_width_bounds(160, engine.effective_left_width(160), true);
        engine.prefs.right_width = Some(bounds.saturating_sub(2));
        assert!(engine.adjust_right_width(1));
        assert_eq!(engine.prefs.right_width, Some(bounds));
        assert!(!engine.adjust_right_width(1));
    }

    #[test]
    fn split_center_live_activity_fits_small_center() {
        crate::tui::theme::install(crate::tui::theme::TuiTheme::default_theme());
        let area = Rect::new(0, 0, 60, 14);
        let center = split_center_column(area, true, 9);
        assert!(center.transcript.height > 0);
        assert!(center.composer.height > 0);
        assert!(center.status.height > 0);
        assert!(center.activity.is_some());
    }

    #[test]
    fn borderless_layout_inserts_gutters() {
        crate::tui::theme::install(crate::tui::theme::TuiTheme::default_theme());
        let engine = LayoutEngine::new(false, TuiLayoutPrefs::default());
        let regions = engine.regions(Rect::new(0, 0, 140, 40));
        assert!(regions.left_visible);
        assert!(regions.right_visible);
        assert_eq!(regions.gutter_left.width, BORDERLESS_GUTTER_COLS);
        assert_eq!(regions.gutter_right.width, BORDERLESS_GUTTER_COLS);
        assert_eq!(regions.left.x + regions.left.width, regions.gutter_left.x);
        assert_eq!(
            regions.gutter_left.x + regions.gutter_left.width,
            regions.center.x
        );
        assert_eq!(
            regions.center.x + regions.center.width,
            regions.gutter_right.x
        );
        assert_eq!(
            regions.gutter_right.x + regions.gutter_right.width,
            regions.right.x
        );
    }

    #[test]
    fn stored_right_width_applied_in_regions() {
        let mut prefs = TuiLayoutPrefs::default();
        prefs.right_width = Some(50);
        let mut engine = LayoutEngine::new(false, prefs);
        engine.last_terminal_width = 160;
        let regions = engine.regions(Rect::new(0, 0, 160, 40));
        assert!(regions.right_visible);
        assert_eq!(regions.right.width, 50);
    }
}