dirge-agent 0.21.5

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
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
//! Layout: single source of truth for UI region geometry.
//!
//! All widgets paint into a `ratatui::Rect` returned from this
//! module. Per-callsite column math in the legacy renderer was the
//! root cause of misaligned frames, ghost borders, and side-panel
//! flicker — concentrating geometry here makes those bugs
//! structurally impossible.

use ratatui::layout::Rect;

/// Maximum chat content width before centering kicks in. Above this
/// the chat band is capped at 120 cols and side panels grow to fill
/// the remaining space symmetrically.
pub const CHAT_CONTENT_MAX_W: u16 = 120;

/// Minimum column width at which the left panel (idle card / subagent
/// list) renders legibly. Below this `render_frame` skips the left panel
/// even when Auto mode reserved space for it, so callers must not reserve
/// a left panel narrower than this. See `PANEL_AUTO_MIN_COLS` in
/// `renderer`.
pub const LEFT_PANEL_MIN_W: u16 = 12;

/// Minimum column width at which the right panel (stacked sub-panels)
/// renders legibly. Below this `render_frame` skips the right panel even
/// when Auto mode reserved space for it. The right panel is the binding
/// constraint on the Auto-mode show threshold (16 > 12): see
/// `PANEL_AUTO_MIN_COLS` in `renderer`.
pub const RIGHT_PANEL_MIN_W: u16 = 16;

/// Maximum input editor row count (matches the legacy
/// `MAX_INPUT_VISIBLE_LINES`). Layout consumers are expected to
/// clamp at this value before passing `input_rows`.
pub const MAX_INPUT_ROWS: u16 = 8;

/// Chat rows kept visible above a modal overlay that has grown past
/// [`MAX_INPUT_ROWS`]. Enough to keep the last couple of agent lines on
/// screen while the user reads the prompt.
pub const MIN_CHAT_ROWS: u16 = 4;

/// Bottom-strip row budget for a modal OVERLAY (permission prompt, shell
/// box) on a `rows`-tall terminal.
///
/// dirge-hzd8 (#744): overlays are not the editor. The editor is capped at
/// [`MAX_INPUT_ROWS`] so a pasted 50-line block can't crowd out the chat,
/// but a permission prompt must show the whole tool call — a user can't
/// judge `rm -rf "$BUILD"/*` from its first three wrapped rows. So an
/// overlay may take everything except [`MIN_CHAT_ROWS`] of chat and the 5
/// fixed frame/status rows (see the vertical layout in [`Layout`]).
pub fn overlay_max_rows(rows: u16) -> u16 {
    rows.saturating_sub(MIN_CHAT_ROWS + 5).max(1)
}

/// All region rects for one frame.
///
/// Layout rows top → bottom:
///
/// ```text
/// row 0                       top_frame    ───[AGENT STATUS]───╭───[AGENT LOG STREAM]───╮───[SYSTEM]───
/// rows 1..=chat_bot_frame-1   chat / left_panel / right_panel  (chat │ on each side)
/// row chat_bot_frame          chat closes (╰───╯ inside chat band, blanks outside)
/// row bottom_strip_top        avatar/input top frame  (╭───╮ inside chat band)
/// rows ..                     avatar (centered face) | input editor | right margin (blank)
/// row bottom_strip_bot        avatar/input bot frame  (╰───╯ inside chat band)
/// row rows-1                  status                            (full-width status line)
/// ```
///
/// Horizontally:
///
/// ```text
/// cols [0, left_panel.right)        left panel content
/// col  left_panel.right - 1         (== chat.left - 1: paint chat's left │ at chat.x - 1)
/// cols [chat.x, chat.right)         chat content (between the │ borders)
/// col  chat.right                   chat's right │
/// cols [chat.right+1, cols)         right panel content
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Layout {
    /// Full terminal viewport — `Rect { x: 0, y: 0, width: cols, height: rows }`.
    pub full: Rect,
    /// Top frame row (single row, full width).
    pub top_frame: Rect,
    /// Chat content inner rect (between the │ borders, top frame, and chat bottom frame).
    pub chat: Rect,
    /// Left panel content (excluding chat's left │).
    pub left_panel: Rect,
    /// Right panel content (excluding chat's right │).
    pub right_panel: Rect,
    /// Column where the chat's left │ sits (row range = chat.y..chat_bot_frame.y).
    pub chat_v_left_col: u16,
    /// Column where the chat's right │ sits.
    pub chat_v_right_col: u16,
    /// Chat bottom frame row (single row, full width — ╰───╯ painted in chat band only).
    pub chat_bot_frame: Rect,
    /// Avatar box rect — bottom-left, mirrors the input box's vertical extent.
    pub avatar_box: Rect,
    /// Input box rect (light rounded frame, between chat verticals).
    pub input_box: Rect,
    /// Right margin in the bottom strip (mirrors avatar_box on the right). Blank.
    pub right_margin: Rect,
    /// Status line row (single row, full width).
    pub status: Rect,
}

impl Layout {
    /// Compute layout for `cols × rows` with `input_rows` editor
    /// lines, with both side panels visible. `input_rows` is clamped
    /// to `[1, MAX_INPUT_ROWS]`.
    ///
    /// On very small terminals some rects collapse to zero width or
    /// height (e.g. left/right panels disappear when the chat fills
    /// the available width). Callers must check `rect.is_empty()`
    /// before painting into a region.
    ///
    /// Production callers go through [`Layout::with_panels`] to honour
    /// per-side visibility; `new` is the both-visible convenience used
    /// throughout the widget tests.
    #[allow(dead_code)]
    pub fn new(cols: u16, rows: u16, input_rows: u16) -> Self {
        Self::with_panels(cols, rows, input_rows, true, true)
    }

    /// Compute layout for `cols × rows` with `input_rows` editor lines,
    /// reserving gutter space only for the side panels that are shown.
    ///
    /// A hidden panel's gutter is reclaimed by the chat band rather
    /// than left blank — so `/display main` widens the conversation to
    /// the full content width, and hiding one side shifts the chat to
    /// absorb that side's gutter while the other panel keeps its width.
    /// When both panels are visible this is identical to [`Layout::new`].
    pub fn with_panels(
        cols: u16,
        rows: u16,
        input_rows: u16,
        show_left: bool,
        show_right: bool,
    ) -> Self {
        Self::with_panels_capped(
            cols,
            rows,
            input_rows,
            show_left,
            show_right,
            MAX_INPUT_ROWS,
        )
    }

    /// [`Layout::with_panels`] with an explicit `max_input_rows` ceiling.
    ///
    /// The editor passes [`MAX_INPUT_ROWS`]; a modal overlay passes
    /// [`overlay_max_rows`] so the permission prompt can grow to fit the
    /// whole tool call instead of being clipped at the editor's cap
    /// (dirge-hzd8 / #744).
    pub fn with_panels_capped(
        cols: u16,
        rows: u16,
        input_rows: u16,
        show_left: bool,
        show_right: bool,
        max_input_rows: u16,
    ) -> Self {
        let input_rows = input_rows.clamp(1, max_input_rows.max(1));
        let full = Rect::new(0, 0, cols, rows);

        // Vertical layout. From bottom up:
        //   status (1) + bot_strip_bot (1) + input_rows + bot_strip_top (1) +
        //   chat_bot_frame (1) + chat_content (N) + top_frame (1) = rows
        // So chat_content_h = rows - input_rows - 5 (saturating).
        let fixed_v = 5_u16; // top_frame + chat_bot_frame + 2 bot-strip frames + status
        let chat_h = rows.saturating_sub(input_rows).saturating_sub(fixed_v);
        let top_frame = Rect::new(0, 0, cols, if rows >= 1 { 1 } else { 0 });
        // Chat content rows 1..1+chat_h
        let chat_content_top = 1_u16.min(rows);
        let chat_bot_frame_y = chat_content_top.saturating_add(chat_h);
        let bottom_strip_top_y = chat_bot_frame_y.saturating_add(1);
        let input_top_y = bottom_strip_top_y.saturating_add(1);
        let bottom_strip_bot_y = input_top_y.saturating_add(input_rows);
        let status_y = rows.saturating_sub(1);

        // Horizontal layout: chat band centered in (cols - 2) so the
        // left/right gutters are symmetric. Each VISIBLE side panel
        // takes `base_gutter` cols; the gutter of a hidden panel is
        // reclaimed by the chat band instead of left blank, so the
        // conversation expands to use the freed space.
        let line_w = cols.saturating_sub(2);
        let base_chat_w = line_w.min(CHAT_CONTENT_MAX_W);
        let base_gutter = line_w.saturating_sub(base_chat_w) / 2;
        let left_gutter = if show_left { base_gutter } else { 0 };
        // Chat keeps its capped width plus whichever gutters are freed.
        let freed =
            if show_left { 0 } else { base_gutter } + if show_right { 0 } else { base_gutter };
        let chat_content_w = base_chat_w.saturating_add(freed);

        let chat_v_left_col = left_gutter; // col of chat's left │
        let chat_x = chat_v_left_col.saturating_add(1);
        let chat_v_right_col = chat_x.saturating_add(chat_content_w);
        // Right panel starts after the right │. Compute its width
        // as remainder so any rounding from the symmetric gutter
        // math doesn't leak (right side absorbs the +1).
        let right_panel_x = chat_v_right_col.saturating_add(1);
        let right_panel_w = cols.saturating_sub(right_panel_x);

        // Chat content rect — between │ borders, rows 1..chat_bot_frame_y.
        let chat = Rect::new(chat_x, chat_content_top, chat_content_w, chat_h);

        let left_panel = Rect::new(0, chat_content_top, left_gutter, chat_h);
        let right_panel = Rect::new(right_panel_x, chat_content_top, right_panel_w, chat_h);

        let chat_bot_frame = Rect::new(0, chat_bot_frame_y, cols, 1);

        // Bottom strip vertical extent = top_frame + input_rows + bot_frame.
        let strip_h = input_rows.saturating_add(2);
        // Avatar box mirrors the left panel cols; input box spans
        // chat verticals inclusive; right margin mirrors right panel cols.
        let avatar_box = Rect::new(0, bottom_strip_top_y, left_gutter, strip_h);
        // Input box includes both chat ║ cols so its rounded ╭...╮
        // sits exactly at the chat verticals — visual continuation.
        let input_box_x = chat_v_left_col;
        let input_box_w = chat_v_right_col
            .saturating_sub(chat_v_left_col)
            .saturating_add(1);
        let input_box = Rect::new(input_box_x, bottom_strip_top_y, input_box_w, strip_h);
        let right_margin_w = cols.saturating_sub(right_panel_x);
        let right_margin = Rect::new(right_panel_x, bottom_strip_top_y, right_margin_w, strip_h);

        // Status: single row at the very bottom.
        let status = Rect::new(0, status_y, cols, if rows >= 1 { 1 } else { 0 });

        let _ = bottom_strip_bot_y; // accounted for via strip_h

        Self {
            full,
            top_frame,
            chat,
            left_panel,
            right_panel,
            chat_v_left_col,
            chat_v_right_col,
            chat_bot_frame,
            avatar_box,
            input_box,
            right_margin,
            status,
        }
    }

    /// Convenience: chat content height in rows.
    #[allow(dead_code)]
    pub fn chat_height(&self) -> u16 {
        self.chat.height
    }
}

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

    /// Wide terminal: chat is capped at CHAT_CONTENT_MAX_W and side
    /// panels split the remaining gutter symmetrically.
    #[test]
    fn wide_terminal_centers_chat() {
        let l = Layout::new(200, 30, 1);
        // cols=200, line_w=198, chat=120, gutter=(198-120)/2=39
        assert_eq!(l.chat_v_left_col, 39);
        assert_eq!(l.chat.x, 40);
        assert_eq!(l.chat.width, CHAT_CONTENT_MAX_W);
        assert_eq!(l.chat_v_right_col, 160);
        // Right panel absorbs the +1 from rounding.
        assert_eq!(l.right_panel.x, 161);
        assert_eq!(l.right_panel.width, 39);
        assert_eq!(l.left_panel.width, 39);
    }

    /// Hiding the right panel reclaims its gutter for the chat band:
    /// the chat grows by the freed gutter width, the left panel keeps
    /// its width, and the right panel collapses to zero.
    #[test]
    fn hiding_right_panel_expands_chat() {
        let both = Layout::new(200, 30, 1);
        let l = Layout::with_panels(200, 30, 1, true, false);
        // Left panel unchanged; right panel gone.
        assert_eq!(l.left_panel.width, both.left_panel.width);
        assert_eq!(l.right_panel.width, 0);
        // Chat absorbs the freed right gutter (no blank reserved space).
        assert_eq!(l.chat.width, both.chat.width + both.right_panel.width);
        assert_eq!(l.chat.x, both.chat.x); // chat start unchanged (left kept)
        // Horizontal tiling still covers the full viewport.
        assert_eq!(
            l.left_panel.width + 1 + l.chat.width + 1 + l.right_panel.width,
            200
        );
    }

    /// Hiding the left panel reclaims its gutter for the chat band and
    /// shifts the chat flush-left.
    #[test]
    fn hiding_left_panel_expands_chat() {
        let both = Layout::new(200, 30, 1);
        let l = Layout::with_panels(200, 30, 1, false, true);
        assert_eq!(l.left_panel.width, 0);
        assert_eq!(l.right_panel.width, both.right_panel.width);
        assert_eq!(l.chat.width, both.chat.width + both.left_panel.width);
        assert_eq!(l.chat_v_left_col, 0);
        assert_eq!(l.chat.x, 1);
        assert_eq!(
            l.left_panel.width + 1 + l.chat.width + 1 + l.right_panel.width,
            200
        );
    }

    /// Hiding both panels gives the entire content band to the chat.
    #[test]
    fn hiding_both_panels_fills_chat() {
        let l = Layout::with_panels(200, 30, 1, false, false);
        assert_eq!(l.left_panel.width, 0);
        assert_eq!(l.right_panel.width, 0);
        assert_eq!(l.chat.width, 198); // cols - 2 (the two │ borders)
        assert_eq!(l.chat.x, 1);
    }

    /// The avatar box (bottom-left) tracks the left gutter, so hiding
    /// the left panel collapses it and the input box expands left.
    #[test]
    fn avatar_box_tracks_left_gutter() {
        let both = Layout::new(200, 30, 1);
        assert_eq!(both.avatar_box.width, both.left_panel.width);
        let left_hidden = Layout::with_panels(200, 30, 1, false, true);
        assert_eq!(left_hidden.avatar_box.width, 0);
        // Input box now starts at col 0 (flush left, no avatar gutter).
        assert_eq!(left_hidden.input_box.x, 0);
    }

    /// `Layout::new` is the both-panels-visible case of `with_panels`.
    #[test]
    fn new_matches_with_panels_both_visible() {
        assert_eq!(
            Layout::new(200, 30, 1),
            Layout::with_panels(200, 30, 1, true, true)
        );
        assert_eq!(
            Layout::new(80, 24, 3),
            Layout::with_panels(80, 24, 3, true, true)
        );
    }

    /// Narrow terminal: line_w <= CHAT_CONTENT_MAX_W → no gutter,
    /// side panels collapse to zero width, chat fills.
    #[test]
    fn narrow_terminal_drops_side_panels() {
        let l = Layout::new(80, 24, 1);
        assert_eq!(l.left_panel.width, 0);
        assert_eq!(l.right_panel.width, 0);
        assert_eq!(l.chat.width, 78); // cols - 2
        assert_eq!(l.chat_v_left_col, 0);
        assert_eq!(l.chat_v_right_col, 79);
    }

    /// Vertical rects tile without overlap and cover the whole
    /// viewport. Sum of region heights == rows (when accounting for
    /// the side-panel cols that the bottom strip overlays).
    #[test]
    fn vertical_tiling_covers_viewport() {
        let l = Layout::new(200, 30, 1);
        let rows = 30_u16;
        // 1 (top) + chat_h + 1 (chat bot) + (2 + input_rows) (strip) + 1 (status) == rows
        assert_eq!(
            1 + l.chat.height + 1 + l.input_box.height + 1,
            rows,
            "vertical tiling: {:?}",
            l
        );
        // Adjacent rects must abut, not overlap.
        assert_eq!(l.top_frame.y + l.top_frame.height, l.chat.y);
        assert_eq!(l.chat.y + l.chat.height, l.chat_bot_frame.y);
        assert_eq!(l.chat_bot_frame.y + l.chat_bot_frame.height, l.input_box.y);
        assert_eq!(l.input_box.y + l.input_box.height, l.status.y);
    }

    /// Horizontally the four regions on a chat row tile: left panel
    /// + chat-│ + chat content + chat-│ + right panel == cols.
    #[test]
    fn horizontal_tiling_on_chat_row() {
        let l = Layout::new(200, 30, 1);
        let cols = 200_u16;
        // left_panel + 1 (left │) + chat + 1 (right │) + right_panel == cols
        let sum = l.left_panel.width + 1 + l.chat.width + 1 + l.right_panel.width;
        assert_eq!(sum, cols, "horizontal tiling: {:?}", l);
        // chat_v_left_col immediately after left_panel.
        assert_eq!(l.chat_v_left_col, l.left_panel.width);
        // chat_v_right_col immediately before right_panel.
        assert_eq!(l.chat_v_right_col, cols - l.right_panel.width - 1);
    }

    /// Input box overlays the chat │ columns so the input frame's
    /// ╭ ╮ ╰ ╯ corners sit exactly at the chat's vertical lines.
    #[test]
    fn input_box_aligns_with_chat_verticals() {
        let l = Layout::new(200, 30, 1);
        assert_eq!(l.input_box.x, l.chat_v_left_col);
        assert_eq!(l.input_box.x + l.input_box.width - 1, l.chat_v_right_col);
    }

    /// Avatar box ends exactly where input box begins; right margin
    /// begins where input box ends. No 1-col gaps anywhere.
    #[test]
    fn bottom_strip_horizontal_tiles() {
        let l = Layout::new(200, 30, 1);
        // avatar.right == input.left.
        assert_eq!(l.avatar_box.x + l.avatar_box.width, l.input_box.x);
        // input.right == right_margin.left.
        assert_eq!(l.input_box.x + l.input_box.width, l.right_margin.x);
        // right_margin.right == cols.
        assert_eq!(l.right_margin.x + l.right_margin.width, 200);
    }

    /// Tall input shrinks chat height by the same amount.
    #[test]
    fn growing_input_shrinks_chat() {
        let one = Layout::new(200, 30, 1);
        let eight = Layout::new(200, 30, 8);
        assert_eq!(one.chat.height - 7, eight.chat.height);
        // Bottom strip starts higher up.
        assert!(eight.input_box.y < one.input_box.y);
        // Status stays at the same row.
        assert_eq!(one.status.y, eight.status.y);
    }

    /// Clamping: input_rows > MAX_INPUT_ROWS clamps to MAX.
    #[test]
    fn input_rows_clamps_to_max() {
        let big = Layout::new(200, 40, 99);
        let max = Layout::new(200, 40, MAX_INPUT_ROWS);
        assert_eq!(big.input_box.height, max.input_box.height);
        assert_eq!(big.chat.height, max.chat.height);
    }

    /// dirge-hzd8 (#744): a modal overlay may exceed the editor's row cap —
    /// the permission prompt has to show the whole command. It still leaves
    /// `MIN_CHAT_ROWS` of chat visible.
    #[test]
    fn overlay_cap_grows_past_editor_cap_but_keeps_a_chat_floor() {
        let rows = 40u16;
        let cap = overlay_max_rows(rows);
        assert!(
            cap > MAX_INPUT_ROWS,
            "a 40-row terminal has room for an overlay taller than the editor cap; got {cap}"
        );
        let l = Layout::with_panels_capped(200, rows, 99, true, true, cap);
        assert_eq!(l.input_box.height, cap + 2, "overlay box grows to the cap");
        assert_eq!(
            l.chat.height, MIN_CHAT_ROWS,
            "the chat floor is exactly MIN_CHAT_ROWS at the cap"
        );
        // Vertical tiling still covers the viewport: top frame + chat +
        // chat bottom frame + strip + status.
        assert_eq!(1 + l.chat.height + 1 + l.input_box.height + 1, rows);
    }

    /// The cap degrades gracefully on short terminals: never 0 (a
    /// zero-height box paints nothing), and never GROWS the overlay past
    /// the editor cap when there isn't room — a 20-row terminal must not
    /// give the prompt 11 rows and leave the chat with nothing.
    #[test]
    fn overlay_max_rows_is_safe_on_short_terminals() {
        for rows in 0..=17u16 {
            let cap = overlay_max_rows(rows);
            assert!(cap >= 1, "rows={rows} yielded a zero cap");
            assert!(
                cap <= MAX_INPUT_ROWS,
                "rows={rows}: overlay cap {cap} exceeds the editor cap on a short terminal"
            );
            // Constructing at the cap stays in-bounds (no panic, no
            // arithmetic overflow on a degenerate viewport).
            let l = Layout::with_panels_capped(80, rows, 99, true, true, cap);
            assert!(l.chat.x + l.chat.width <= 80);
        }
        // Room appears at rows = MIN_CHAT_ROWS + 5 + MAX_INPUT_ROWS + 1.
        assert!(overlay_max_rows(18) > MAX_INPUT_ROWS);
    }

    /// The editor is NOT affected by the overlay cap — `with_panels` still
    /// clamps at MAX_INPUT_ROWS so a pasted 50-line block can't swallow the
    /// chat.
    #[test]
    fn editor_still_clamps_to_max_input_rows_on_a_tall_terminal() {
        let l = Layout::with_panels(200, 60, 99, true, true);
        assert_eq!(l.input_box.height, MAX_INPUT_ROWS + 2);
    }

    /// Degenerate viewport: very small rows shouldn't panic or
    /// produce negative widths.
    #[test]
    fn degenerate_small_viewport_is_safe() {
        let l = Layout::new(20, 6, 1);
        // chat_h = 6 - 1 - 5 = 0; everything else gracefully
        // collapses but stays in-bounds.
        assert!(l.chat.x + l.chat.width <= 20);
        assert!(l.status.y < 6);
    }
}