use crate::model::{Role, Session};
pub struct WindowOpts {
pub tool_head: usize,
pub tool_tail: usize,
pub per_msg_chars: usize,
pub budget_chars: Option<usize>,
}
impl Default for WindowOpts {
fn default() -> Self {
WindowOpts {
tool_head: 3,
tool_tail: 3,
per_msg_chars: 3200,
budget_chars: None,
}
}
}
fn fold_tool(text: &str, head: usize, tail: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
if lines.len() <= head + tail + 1 {
return text.trim_end().to_string();
}
let elided = lines.len() - head - tail;
let mut out: Vec<String> = lines[..head].iter().map(|s| s.to_string()).collect();
out.push(format!(" [… {elided} lines …]"));
out.extend(lines[lines.len() - tail..].iter().map(|s| s.to_string()));
out.join("\n")
}
fn cap_msg(text: &str, max: usize) -> String {
let t = text.trim();
if t.chars().count() <= max {
return t.to_string();
}
let half = max / 2;
let head: String = t.chars().take(half).collect();
let tail: String = {
let all: Vec<char> = t.chars().collect();
all[all.len() - half..].iter().collect()
};
let elided = t.chars().count() - 2 * half;
format!("{head}\n [… {elided} chars …]\n{tail}")
}
fn render_msg(m: &crate::model::Message, opts: &WindowOpts) -> String {
match m.role {
Role::User => format!("[user]\n{}", cap_msg(&m.text, opts.per_msg_chars)),
Role::Assistant => format!("[assistant]\n{}", cap_msg(&m.text, opts.per_msg_chars)),
Role::Tool => format!(
"[tool]\n{}",
fold_tool(&m.text, opts.tool_head, opts.tool_tail)
),
}
}
pub fn render_window(session: &Session, opts: &WindowOpts) -> String {
let started = session
.started
.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "?".into());
let header = format!(
"## {} [{}] · {} · {} · {} messages",
session.title,
session.tool,
if session.project.is_empty() {
"(no project)"
} else {
&session.project
},
started,
session.messages.len()
);
let blocks: Vec<String> = session
.messages
.iter()
.map(|m| render_msg(m, opts))
.collect();
let (kept, omitted) = match opts.budget_chars {
None => (blocks, 0usize),
Some(budget) => {
let mut acc = 0usize;
let mut taken: Vec<String> = Vec::new();
for b in blocks.iter().rev() {
if acc + b.len() > budget && !taken.is_empty() {
break;
}
acc += b.len();
taken.push(b.clone());
}
let omitted = blocks.len() - taken.len();
taken.reverse();
(taken, omitted)
}
};
let mut out = String::new();
out.push_str(&header);
out.push_str("\n\n");
if omitted > 0 {
out.push_str(&format!("[… {omitted} earlier turn(s) omitted …]\n\n"));
}
out.push_str(&kept.join("\n\n"));
out.push_str(&format!(
"\n\n→ full: sessionwiki show {} --full",
session.id
));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Message, Role, Session};
fn msg(role: Role, text: &str) -> Message {
Message {
role,
text: text.to_string(),
ts: None,
}
}
fn session(messages: Vec<Message>) -> Session {
Session {
id: "abc123".into(),
tool: "codex",
path: std::path::PathBuf::from("/x.jsonl"),
project: "proj".into(),
started: None,
ended: None,
title: "refactor token guard".into(),
subagent: false,
messages,
touched: vec![],
}
}
#[test]
fn folds_tool_output_to_head_and_tail() {
let big = (1..=50)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let folded = fold_tool(&big, 3, 3);
assert!(
folded.contains("line1") && folded.contains("line3"),
"head kept"
);
assert!(
folded.contains("line48") && folded.contains("line50"),
"tail kept"
);
assert!(
folded.contains("[… 44 lines …]"),
"middle elided with count"
);
assert!(!folded.contains("line25"), "bulk dropped");
assert_eq!(fold_tool("a\nb", 3, 3), "a\nb");
}
#[test]
fn window_has_header_roles_tool_fold_and_drilldown() {
let big_tool = (1..=40)
.map(|i| format!("l{i}"))
.collect::<Vec<_>>()
.join("\n");
let s = session(vec![
msg(
Role::User,
"refuse switch when a session runs on the same slot",
),
msg(Role::Assistant, "adding a fail-closed check; running tests"),
msg(Role::Tool, &big_tool),
]);
let w = render_window(&s, &WindowOpts::default());
assert!(
w.starts_with("## refactor token guard [codex]"),
"orientation header"
);
assert!(
w.contains("[user]") && w.contains("[assistant]") && w.contains("[tool]"),
"role labels"
);
assert!(w.contains("[… 34 lines …]"), "tool result folded");
assert!(
w.contains("adding a fail-closed check"),
"assistant text kept verbatim"
);
assert!(
w.contains("→ full: sessionwiki show abc123 --full"),
"drill-down hint"
);
}
#[test]
fn budget_keeps_the_recent_tail_and_marks_omissions() {
let msgs: Vec<Message> = (0..20)
.map(|i| msg(Role::User, &format!("turn number {i} with some words")))
.collect();
let s = session(msgs);
let opts = WindowOpts {
budget_chars: Some(120),
..Default::default()
};
let w = render_window(&s, &opts);
assert!(w.contains("turn number 19"), "most recent kept");
assert!(!w.contains("turn number 0"), "oldest dropped by budget");
assert!(w.contains("earlier turn(s) omitted"), "omission is marked");
}
}