Skip to content
85 changes: 39 additions & 46 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,29 +540,25 @@ impl App {
}
}

/// Move the selection one task up in display order; up from the first
/// task wraps to the last.
fn select_up(&mut self) {
/// Move the selection one task forward or backward in display order,
/// wrapping at either end.
fn select_wrap(&mut self, forward: bool) {
let order = self.display_order();
if order.is_empty() {
self.selected_id = None;
return;
}
let pos = self.selected_pos(&order).unwrap_or(0);
self.selected_id = Some(self.views[order[(pos + order.len() - 1) % order.len()]].id);
let step = if forward { 1 } else { order.len() - 1 };
self.selected_id = Some(self.views[order[(pos + step) % order.len()]].id);
}

fn select_up(&mut self) {
self.select_wrap(false);
}

/// Move the selection one task down in display order; down from the last
/// task wraps to the first.
fn select_down(&mut self) {
let order = self.display_order();
if order.is_empty() {
self.selected_id = None;
return;
}
let pos = self.selected_pos(&order).unwrap_or(0);
let next = (pos + 1) % order.len();
self.selected_id = Some(self.views[order[next]].id);
self.select_wrap(true);
}

/// Index within `sections` of the section holding the selected task.
Expand All @@ -573,34 +569,30 @@ impl App {
.position(|(_, idxs)| idxs.iter().any(|&i| self.views[i].id == id))
}

/// Select the first task in the next section, wrapping to the first.
/// With no current selection, select the first section.
fn select_next_section(&mut self) {
/// Select the first task in the next or previous section, wrapping at
/// either end. Without a selection, choose the first or last section.
fn select_section_wrap(&mut self, forward: bool) {
let sections = self.sections();
if sections.is_empty() {
self.selected_id = None;
return;
}
let next = match self.selected_section(&sections) {
Some(cur) => (cur + 1) % sections.len(),
None => 0,
let len = sections.len();
let target = match (self.selected_section(&sections), forward) {
(Some(cur), true) => (cur + 1) % len,
(Some(cur), false) => (cur + len - 1) % len,
(None, true) => 0,
(None, false) => len - 1,
};
self.selected_id = Some(self.views[sections[next].1[0]].id);
self.selected_id = Some(self.views[sections[target].1[0]].id);
}

fn select_next_section(&mut self) {
self.select_section_wrap(true);
}

/// Select the first task in the previous section, wrapping to the last.
/// With no current selection, select the last section.
fn select_prev_section(&mut self) {
let sections = self.sections();
if sections.is_empty() {
self.selected_id = None;
return;
}
let prev = match self.selected_section(&sections) {
Some(cur) => (cur + sections.len() - 1) % sections.len(),
None => sections.len() - 1,
};
self.selected_id = Some(self.views[sections[prev].1[0]].id);
self.select_section_wrap(false);
}

/// Send the desired watch state when its target or attachment mode changes.
Expand Down Expand Up @@ -694,7 +686,7 @@ impl App {
ClipboardKind::Selection => 's',
};
write!(out, "\x1b]52;{k};{}\x07", B64.encode(&text))?;
last = copied_chars(&text);
last = text.chars().count();
}
out.flush()?;
// Show the confirmation in the attached-mode notice bar.
Expand Down Expand Up @@ -948,6 +940,11 @@ impl App {
self.group_candidates = cands;
}

/// Return whether nonempty input matches no existing group.
pub(crate) fn group_is_new(&self) -> bool {
!self.group_input.is_empty() && self.group_candidates.len() < 2
}

/// Clear the group-picker state and return to the dashboard.
fn close_group_picker(&mut self) {
self.group_input.clear();
Expand Down Expand Up @@ -1212,7 +1209,7 @@ impl App {
KeyCode::Enter => {
// Enter assigns the highlighted group, or creates the typed
// group when no existing name matches.
let group = if !self.group_input.is_empty() && self.group_candidates.len() < 2 {
let group = if self.group_is_new() {
Some(self.group_input.as_str().to_string())
} else {
self.group_candidates
Expand Down Expand Up @@ -1283,11 +1280,7 @@ impl App {
// Other input returns to live and is forwarded immediately.
_ => {
self.view_scroll = false;
if let Some(id) = self.focused_id
&& let Some((code, mods)) = key_event_to_key(k)
{
self.transport.send(Command::Key { id, code, mods });
}
self.forward_key(k);
}
}
return Ok(());
Expand All @@ -1301,12 +1294,17 @@ impl App {
self.send_scrollback(ScrollAction::Up(page));
return Ok(());
}
self.forward_key(k);
Ok(())
}

/// Forward an encodable keystroke to the focused task's PTY.
fn forward_key(&mut self, k: KeyEvent) {
if let Some(id) = self.focused_id
&& let Some((code, mods)) = key_event_to_key(k)
{
self.transport.send(Command::Key { id, code, mods });
}
Ok(())
}

/// Route a clipboard paste by mode. Attached pastes go intact to the core,
Expand Down Expand Up @@ -1565,11 +1563,6 @@ fn on_key_edit(buf: &mut EditBuffer, k: KeyEvent) -> Option<bool> {
}
}

/// Count the characters reported by the clipboard-copy notice.
fn copied_chars(text: &str) -> usize {
text.chars().count()
}

/// Insert pasted text at the caret after removing control characters.
fn paste_into(buf: &mut EditBuffer, s: &str) {
for c in s.chars().filter(|c| !c.is_control()) {
Expand Down
24 changes: 23 additions & 1 deletion src/terminal/format.rs → src/format.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Small display helpers: relative time and column-bounded truncation.
//! Formatting, display-width, and civil-date helpers.

use std::time::Duration;

Expand Down Expand Up @@ -75,6 +75,19 @@ pub fn pad(s: &str, width: usize) -> String {
t
}

/// Convert days since 1970-01-01 to a proleptic Gregorian date.
pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(yoe + era * 400 + i64::from(m <= 2), m, d)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -135,4 +148,13 @@ mod tests {
// Combining mark: 1 column, so 2 spaces of padding.
assert_eq!(pad("e\u{0301}", 3), "e\u{0301} ");
}

#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1)); // leap year start
assert_eq!(civil_from_days(19_782), (2024, 2, 29)); // leap day
assert_eq!(civil_from_days(20_648), (2026, 7, 14));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
}
}
29 changes: 15 additions & 14 deletions src/harness/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,21 @@ exec "$@"

/// Build the `claude` settings overlay containing the `SessionStart` hook.
fn claude_settings_json() -> String {
let mut hook = jzon::JsonValue::new_object();
let _ = hook.insert("type", "command");
let _ = hook.insert("command", format!("cat > \"${}\"", super::CAPTURE_ENV));
let mut inner = jzon::JsonValue::new_array();
let _ = inner.push(hook);
let mut matcher = jzon::JsonValue::new_object();
let _ = matcher.insert("hooks", inner);
let mut starts = jzon::JsonValue::new_array();
let _ = starts.push(matcher);
let mut hooks = jzon::JsonValue::new_object();
let _ = hooks.insert("SessionStart", starts);
let mut root = jzon::JsonValue::new_object();
let _ = root.insert("hooks", hooks);
root.dump()
jzon::object! {
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": format!("cat > \"${}\"", super::CAPTURE_ENV),
},
],
},
],
},
}
.dump()
}

/// Resolve the capture root from an explicit runtime directory, the platform
Expand Down
28 changes: 2 additions & 26 deletions src/harness/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ impl Harness for Codex {
let spawn_days = (spawn_ms / 86_400_000) as i64;
let mut survivors: Vec<String> = Vec::new();
for day in (spawn_days - 2)..=(spawn_days + 2) {
let (y, m, d) = civil_from_days(day);
let (y, m, d) = crate::format::civil_from_days(day);
let dir = root
.join("sessions")
.join(format!("{y:04}"))
Expand Down Expand Up @@ -349,21 +349,6 @@ fn line1_cwd_matches(path: &Path, cwd: &Path) -> bool {
.is_some_and(|c| Path::new(c) == cwd)
}

/// Proleptic Gregorian date for a count of days since 1970-01-01.
/// `pub(crate)` for the shared rollout fixture in `testutil`; the module
/// itself stays private, so the path runs through `harness`'s re-export.
pub(crate) fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(yoe + era * 400 + i64::from(m <= 2), m, d)
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;
Expand Down Expand Up @@ -764,7 +749,7 @@ mod tests {
let spawned = SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(spawn_ms);

let id = v7_at(spawn_ms + 2_000, 7);
let (y, m, d) = civil_from_days((spawn_ms / 86_400_000) as i64 - 1);
let (y, m, d) = crate::format::civil_from_days((spawn_ms / 86_400_000) as i64 - 1);
let dir = home
.join("sessions")
.join(format!("{y:04}"))
Expand All @@ -788,15 +773,6 @@ mod tests {
let _ = fs::remove_dir_all(&home);
}

#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1)); // leap year start
assert_eq!(civil_from_days(19_782), (2024, 2, 29)); // leap day
assert_eq!(civil_from_days(20_648), (2026, 7, 14));
assert_eq!(civil_from_days(-1), (1969, 12, 31));
}

/// The scraper recovers an SGR-split exit hint from the corpus bytes after
/// terminal emulation removes the styling.
#[test]
Expand Down
1 change: 0 additions & 1 deletion src/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ use std::{

pub use claude::Claude;
pub use codex::Codex;
pub(crate) use codex::civil_from_days;
pub use grok::Grok;

/// Environment variable naming the capture file used by injected assets.
Expand Down
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mod protocol;
mod session;
mod transport;

mod format;
mod harness;
mod path;
mod terminal;
Expand All @@ -33,7 +34,7 @@ mod terminal;
#[cfg(test)]
mod testutil;

pub(crate) use terminal::{ansi, emulator, format, frame, input};
pub(crate) use terminal::{ansi, emulator, frame, input};

use std::{
io::{self, IsTerminal},
Expand Down
Loading