dirge-agent 0.18.4

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
//! 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;

/// 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 {
        let input_rows = input_rows.clamp(1, MAX_INPUT_ROWS);
        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);
    }

    /// 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);
    }
}