bohay 0.9.1

Next-Gen Agents multiplexer
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
//! The file-view model (docs/38 FILE-3): one open file rendered natively inside
//! a pane or a tab. Pure state; the bytes are read on a worker thread and folded
//! in via [`FileView::apply`]. Rendering is O(visible rows) — the renderer slices
//! `lines` to the viewport.

use std::path::{Path, PathBuf};

/// Files larger than this are not read into memory — a viewer is not an excuse
/// to allocate hundreds of MB on a whim.
pub const SIZE_CAP: u64 = 5 * 1024 * 1024;

/// How many leading bytes decide "binary": a NUL in here means don't try to
/// render it as text.
const SNIFF: usize = 8192;

/// The outcome of reading a file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileLoad {
    /// The read is in flight.
    Loading,
    /// Decoded text, one entry per line (tabs already expanded).
    Text(Vec<String>),
    /// Binary content (a NUL byte was found); carries the byte size.
    Binary(u64),
    /// Over [`SIZE_CAP`]; carries the byte size.
    TooLarge(u64),
    /// The read failed; carries a human-readable reason.
    Error(String),
}

/// A live in-file search over the loaded text.
#[derive(Clone, Debug, Default)]
pub struct Search {
    /// The query being typed / active (lowercased match is case-insensitive).
    pub query: String,
    /// True while the user is still typing the query (before Enter).
    pub editing: bool,
    /// `(line, start_col)` of every match, in document order.
    pub matches: Vec<(usize, usize)>,
    /// Index into `matches` of the current hit.
    pub current: usize,
}

/// One open file: what it is, and where the viewport sits.
pub struct FileView {
    pub path: PathBuf,
    pub load: FileLoad,
    /// First visible line.
    pub scroll: usize,
    /// Horizontal scroll (columns), ignored when `wrap`.
    pub hscroll: u16,
    /// Soft-wrap long lines instead of clipping + horizontal scroll.
    pub wrap: bool,
    /// The file's mtime at the last read — drives live refresh (docs/38 FILE-5).
    pub mtime: Option<std::time::SystemTime>,
    /// In-file search state (docs/38 FILE-6), `None` when not searching.
    pub search: Option<Search>,
}

impl FileView {
    pub fn new(path: PathBuf) -> Self {
        FileView {
            path,
            load: FileLoad::Loading,
            scroll: 0,
            hscroll: 0,
            // Soft-wrap on by default: a reader should never hide content off the
            // right edge. `w` toggles to no-wrap + horizontal scroll for code.
            wrap: true,
            mtime: None,
            search: None,
        }
    }

    /// Fold a finished read in. Scroll is **kept** (clamped to the new content),
    /// so a live refresh (docs/38 FILE-5) doesn't yank the reader back to the
    /// top; a fresh open already has scroll at 0. An active search is
    /// re-evaluated against the new text.
    pub fn apply(&mut self, load: FileLoad) {
        self.load = load;
        let max = self.line_count().saturating_sub(1);
        self.scroll = self.scroll.min(max);
        self.hscroll = 0;
        if let Some(s) = self.search.take() {
            if !s.query.is_empty() {
                self.run_search(&s.query);
            }
        }
    }

    pub fn line_count(&self) -> usize {
        match &self.load {
            FileLoad::Text(lines) => lines.len(),
            _ => 0,
        }
    }

    /// Scroll vertically by `delta` lines, clamped so at least one line stays on
    /// screen. `viewport` is the number of text rows currently visible.
    pub fn scroll_by(&mut self, delta: i32, viewport: usize) {
        let max = self.line_count().saturating_sub(1);
        let next = (self.scroll as i32 + delta).clamp(0, max as i32) as usize;
        self.scroll = next;
        // Also clamp so the last page doesn't scroll into empty space.
        let last_top = self.line_count().saturating_sub(viewport.max(1));
        if self.scroll > last_top {
            self.scroll = last_top;
        }
    }

    pub fn goto_top(&mut self) {
        self.scroll = 0;
    }

    pub fn goto_bottom(&mut self, viewport: usize) {
        self.scroll = self.line_count().saturating_sub(viewport.max(1));
    }

    pub fn scroll_right(&mut self, delta: i16) {
        if self.wrap {
            return;
        }
        self.hscroll = (self.hscroll as i16 + delta).max(0) as u16;
    }

    // ── search (docs/38 FILE-6) ──────────────────────────────────────────────

    /// Begin typing a query.
    pub fn search_begin(&mut self) {
        self.search = Some(Search {
            editing: true,
            ..Default::default()
        });
    }

    /// A char typed into the active query.
    pub fn search_push(&mut self, c: char) {
        if let Some(s) = self.search.as_mut().filter(|s| s.editing) {
            s.query.push(c);
        }
    }

    /// Backspace in the active query.
    pub fn search_backspace(&mut self) {
        if let Some(s) = self.search.as_mut().filter(|s| s.editing) {
            s.query.pop();
        }
    }

    /// Commit the query: compute matches and jump to the first at/after the
    /// current scroll position.
    pub fn search_commit(&mut self) {
        let Some(query) = self.search.as_ref().map(|s| s.query.clone()) else {
            return;
        };
        if query.is_empty() {
            self.search = None;
            return;
        }
        self.run_search(&query);
    }

    /// Cancel search entirely.
    pub fn search_cancel(&mut self) {
        self.search = None;
    }

    /// Step to the next (`forward`) / previous match, wrapping, and scroll it
    /// into view.
    pub fn search_step(&mut self, forward: bool, viewport: usize) {
        let (len, next) = match self.search.as_ref() {
            Some(s) if !s.matches.is_empty() => {
                let n = s.matches.len();
                let cur = s.current;
                (
                    n,
                    if forward {
                        (cur + 1) % n
                    } else {
                        (cur + n - 1) % n
                    },
                )
            }
            _ => return,
        };
        if let Some(s) = self.search.as_mut() {
            s.current = next;
        }
        let _ = len;
        self.reveal_current_match(viewport);
    }

    fn run_search(&mut self, query: &str) {
        let needle = query.to_lowercase();
        let mut matches = Vec::new();
        if let FileLoad::Text(lines) = &self.load {
            for (li, line) in lines.iter().enumerate() {
                let hay = line.to_lowercase();
                let mut from = 0;
                while let Some(rel) = hay[from..].find(&needle) {
                    let col = from + rel;
                    matches.push((li, col));
                    from = col + needle.len().max(1);
                }
            }
        }
        // Jump to the first match at/after the current viewport top.
        let current = matches
            .iter()
            .position(|(l, _)| *l >= self.scroll)
            .unwrap_or(0);
        self.search = Some(Search {
            query: query.to_string(),
            editing: false,
            matches,
            current,
        });
    }

    fn reveal_current_match(&mut self, viewport: usize) {
        if let Some(s) = &self.search {
            if let Some((line, _)) = s.matches.get(s.current).copied() {
                // Center-ish: keep the match on screen.
                if line < self.scroll || line >= self.scroll + viewport.max(1) {
                    self.scroll = line.saturating_sub(viewport / 2);
                }
            }
        }
    }
}

/// The line-number gutter width for a file of `line_count` lines. Shared by the
/// renderer and mouse-selection extraction so their column math agrees.
pub fn gutter_width(line_count: usize) -> u16 {
    (line_count.max(1).to_string().len() as u16 + 1).max(4)
}

/// Character ranges `(start, end)` of each visual segment when `line` is
/// soft-wrapped to `width` columns. Breaks on the last space inside the window
/// when there is one (word wrap), else hard-splits at the width. Always returns
/// at least one range, so an empty line still occupies a row. Shared by the
/// renderer and mouse-selection so a wrapped view maps screen rows to file
/// columns identically in both.
pub fn wrap_ranges(line: &str, width: usize) -> Vec<(usize, usize)> {
    let chars: Vec<char> = line.chars().collect();
    let n = chars.len();
    if width == 0 || n <= width {
        return vec![(0, n)];
    }
    let mut out = Vec::new();
    let mut start = 0;
    while start < n {
        if n - start <= width {
            out.push((start, n));
            break;
        }
        let hard_end = start + width;
        let mut brk = hard_end;
        // Prefer a word boundary: the last space in the window, if it isn't the
        // very first column (which would make an empty segment).
        if let Some(pos) = chars[start..hard_end].iter().rposition(|&c| c == ' ') {
            let abs = start + pos;
            if abs > start {
                brk = abs;
            }
        }
        out.push((start, brk));
        // Swallow the space we broke on so it doesn't lead the next row.
        start = if brk < n && chars[brk] == ' ' {
            brk + 1
        } else {
            brk
        };
    }
    if out.is_empty() {
        out.push((0, n));
    }
    out
}

/// Slice the `(start, end)` char range out of `line`.
pub fn seg_text(line: &str, range: (usize, usize)) -> String {
    line.chars().skip(range.0).take(range.1 - range.0).collect()
}

/// Extract the text under a mouse selection over a file view (docs/38), so
/// drag-to-copy works like a pane. `content` is the view's content rect and
/// `((sx,sy),(ex,ey))` the selection in reading order (terminal cells).
///
/// Maps each selected screen row to a file line (via `scroll`) and each column
/// past the line-number gutter to a text column (via `hscroll`). Soft-wrap makes
/// the row→line mapping non-linear, so a wrapped view copies whole lines in the
/// row range rather than a precise sub-range.
pub fn selection_text(
    v: &FileView,
    content: ratatui::layout::Rect,
    ordered: ((u16, u16), (u16, u16)),
) -> Option<String> {
    let FileLoad::Text(lines) = &v.load else {
        return None;
    };
    let ((sx, sy), (ex, ey)) = ordered;
    let gutter = gutter_width(lines.len());
    let text_x = content.x + gutter + 1;

    // Build the same screen-row → (file line, segment char range) map the
    // renderer draws, so a drag maps to the right columns in both wrap modes.
    // No-wrap is one full-width segment per line (with horizontal scroll);
    // wrap breaks each line into its visual segments.
    let text_w = content.width.saturating_sub(gutter + 1) as usize;
    let rows = content.height as usize;
    let mut rowmap: Vec<(usize, usize, usize)> = Vec::new(); // (line, seg_start, seg_end)
    let mut li = v.scroll;
    'build: while li < lines.len() {
        if v.wrap {
            for (s, e) in wrap_ranges(&lines[li], text_w) {
                rowmap.push((li, s, e));
                if rowmap.len() >= rows {
                    break 'build;
                }
            }
        } else {
            let n = lines[li].chars().count();
            rowmap.push((li, 0, n));
            if rowmap.len() >= rows {
                break 'build;
            }
        }
        li += 1;
    }

    let mut out = String::new();
    let mut first = true;
    for ty in sy..=ey {
        let vi = (ty.saturating_sub(content.y)) as usize;
        let Some(&(line, seg_s, seg_e)) = rowmap.get(vi) else {
            continue;
        };
        let chars: Vec<char> = lines
            .get(line)
            .map(|l| l.chars().collect())
            .unwrap_or_default();
        // Screen column → char within this segment. No-wrap adds horizontal scroll.
        let to_col = |screen_x: u16| {
            (screen_x.saturating_sub(text_x)) as usize + if v.wrap { 0 } else { v.hscroll as usize }
        };
        let start = seg_s + if ty == sy { to_col(sx) } else { 0 };
        let end = if ty == ey {
            seg_s + to_col(ex) + 1
        } else {
            seg_e
        };
        let (start, end) = (
            start.min(seg_e).min(chars.len()),
            end.min(seg_e).min(chars.len()),
        );
        let seg: String = if start < end {
            chars[start..end].iter().collect()
        } else {
            String::new()
        };
        if !first {
            out.push('\n');
        }
        first = false;
        out.push_str(seg.trim_end());
    }
    let out = out.trim_end_matches('\n').to_string();
    (!out.is_empty()).then_some(out)
}

/// Read `path` off the loop into a [`FileLoad`]. Never panics: a missing file,
/// permission error, oversize file, or binary content each becomes a variant.
pub fn read_file(path: &Path) -> FileLoad {
    let meta = match std::fs::metadata(path) {
        Ok(m) => m,
        Err(e) => return FileLoad::Error(e.to_string()),
    };
    if meta.len() > SIZE_CAP {
        return FileLoad::TooLarge(meta.len());
    }
    let bytes = match std::fs::read(path) {
        Ok(b) => b,
        Err(e) => return FileLoad::Error(e.to_string()),
    };
    if bytes.iter().take(SNIFF).any(|&b| b == 0) {
        return FileLoad::Binary(meta.len());
    }
    // Lossy UTF-8, split on \n, strip a trailing \r, expand tabs to 4 columns so
    // horizontal scroll and width math stay simple.
    let text = String::from_utf8_lossy(&bytes);
    let lines: Vec<String> = text
        .split('\n')
        .map(|l| l.strip_suffix('\r').unwrap_or(l).replace('\t', "    "))
        .collect();
    // A trailing newline yields a final empty element; drop it so the line count
    // matches what an editor shows.
    let lines = if lines.len() > 1 && lines.last().is_some_and(|l| l.is_empty()) {
        lines[..lines.len() - 1].to_vec()
    } else {
        lines
    };
    FileLoad::Text(lines)
}

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

    #[test]
    fn reads_text_binary_and_oversize() {
        let dir = std::env::temp_dir().join(format!("bohay-fv-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        std::fs::write(dir.join("t.txt"), b"a\nb\tc\n").unwrap();
        assert_eq!(
            read_file(&dir.join("t.txt")),
            FileLoad::Text(vec!["a".into(), "b    c".into()]),
            "tabs expanded, trailing newline dropped"
        );

        std::fs::write(dir.join("b.bin"), [0u8, 1, 2, 3]).unwrap();
        assert!(matches!(read_file(&dir.join("b.bin")), FileLoad::Binary(4)));

        assert!(matches!(
            read_file(&dir.join("missing")),
            FileLoad::Error(_)
        ));

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn wrap_ranges_word_wraps_and_hard_splits() {
        // Word wrap: breaks on the last space in the window, swallowing it.
        let r = wrap_ranges("the quick brown fox", 10);
        let segs: Vec<String> = r
            .iter()
            .map(|&rg| seg_text("the quick brown fox", rg))
            .collect();
        assert_eq!(segs, vec!["the quick", "brown fox"]);
        // No spaces (e.g. a long token / code): hard split at the width, nothing lost.
        let r = wrap_ranges("abcdefghijk", 4);
        let segs: Vec<String> = r.iter().map(|&rg| seg_text("abcdefghijk", rg)).collect();
        assert_eq!(segs, vec!["abcd", "efgh", "ijk"]);
        assert_eq!(
            segs.concat(),
            "abcdefghijk",
            "every character survives the wrap"
        );
        // Short line and empty line each stay a single row.
        assert_eq!(wrap_ranges("hi", 10), vec![(0, 2)]);
        assert_eq!(wrap_ranges("", 10), vec![(0, 0)]);
    }

    #[test]
    fn wrap_is_the_default() {
        assert!(
            FileView::new(PathBuf::from("/x")).wrap,
            "a file opens wrapped"
        );
    }

    #[test]
    fn scroll_clamps_to_content() {
        let mut v = FileView::new(PathBuf::from("/x"));
        v.apply(FileLoad::Text((0..10).map(|i| i.to_string()).collect()));
        v.scroll_by(100, 4); // viewport 4 rows, 10 lines → last top is 6
        assert_eq!(v.scroll, 6);
        v.scroll_by(-100, 4);
        assert_eq!(v.scroll, 0);
    }

    #[test]
    fn search_finds_navigates_and_reveals() {
        let mut v = FileView::new(PathBuf::from("/x"));
        v.apply(FileLoad::Text(vec![
            "let foo = 1;".into(),
            "// nothing here".into(),
            "foo(foo, FOO);".into(), // 3 hits (case-insensitive) on line 2
        ]));
        v.search_begin();
        for c in "foo".chars() {
            v.search_push(c);
        }
        v.search_commit();
        let s = v.search.as_ref().unwrap();
        // 1 on line 0, 3 on line 2 (foo, foo, FOO) = 4 total.
        assert_eq!(s.matches.len(), 4);
        assert_eq!(s.current, 0);

        // Next wraps through all four; N goes back.
        v.search_step(true, 2);
        assert_eq!(v.search.as_ref().unwrap().current, 1);
        v.search_step(false, 2);
        assert_eq!(v.search.as_ref().unwrap().current, 0);

        // Stepping to a match far down scrolls it into view.
        v.goto_top();
        v.search_step(true, 1); // current -> 1, which is on line 2
        assert!(v.scroll >= 1, "the match line was revealed");

        // A refreshed read re-evaluates the query against new text.
        v.apply(FileLoad::Text(vec!["only foo".into()]));
        assert_eq!(v.search.as_ref().unwrap().matches.len(), 1);

        v.search_cancel();
        assert!(v.search.is_none());
    }
}