#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NarrativeTone {
Observe,
Perceive,
Search,
Infer,
Act,
Verify,
Learn,
Recover,
}
#[derive(Debug, Clone)]
pub struct NarrativeBeat {
pub tone: NarrativeTone,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Act {
One,
Two,
Three,
}
#[inline]
fn humor_check(humor: u8, threshold: u8) -> bool {
humor >= threshold
}
fn short_path(p: &str, max: usize) -> String {
if p.is_empty() {
return "a file".into();
}
if p.chars().count() <= max {
return p.to_string();
}
if let Some(last) = p.rsplit('/').next() {
return last.to_string();
}
p.chars().take(max).collect()
}
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max - 1).collect();
out.push('…');
out
}
pub fn narrate_tool_call(
tool: &str,
path: Option<&str>,
pattern: Option<&str>,
command: Option<&str>,
commit_message: Option<&str>,
humor: u8,
act: Act,
) -> Option<NarrativeBeat> {
let path_str = path.unwrap_or("");
let pat = pattern.unwrap_or("").trim();
match tool {
"read_file" => {
let p = short_path(path_str, 36);
let text = match act {
Act::One => format!("Surveying {p}."),
Act::Three => format!("Final look at {p}."),
Act::Two => format!("Reading {p}."),
};
Some(NarrativeBeat {
tone: NarrativeTone::Perceive,
text,
})
}
"grep_files" => {
if pat.is_empty() {
return None;
}
let prefix = if matches!(act, Act::One) {
"Searching for"
} else {
"Looking for"
};
Some(NarrativeBeat {
tone: NarrativeTone::Search,
text: format!("{prefix} \"{}\".", clip(pat, 28)),
})
}
"find_files" => {
if pat.is_empty() {
return None;
}
let prefix = if matches!(act, Act::One) {
"Mapping"
} else {
"Scanning for"
};
Some(NarrativeBeat {
tone: NarrativeTone::Search,
text: format!("{prefix} {}.", clip(pat, 32)),
})
}
"list_dir" => {
let bare = path_str.is_empty() || path_str == "." || path_str == "./";
let p = short_path(path_str, 36);
let text = match (act, bare) {
(Act::One, true) => "Getting the lay of the land.".into(),
(Act::One, false) => format!("Orienting. {p}."),
(_, true) => "Surveying the workspace.".into(),
(_, false) => format!("Checking {p}."),
};
Some(NarrativeBeat {
tone: NarrativeTone::Perceive,
text,
})
}
"write_file" => {
let p = short_path(path_str, 36);
let text = if matches!(act, Act::Three) {
format!("Finalizing {p}.")
} else {
format!("Writing {p}.")
};
Some(NarrativeBeat {
tone: NarrativeTone::Act,
text,
})
}
"edit_file" => {
let p = short_path(path_str, 36);
let text = if matches!(act, Act::Three) && humor_check(humor, 75) {
format!("Last touch — {p}.")
} else {
format!("Editing {p}.")
};
Some(NarrativeBeat {
tone: NarrativeTone::Act,
text,
})
}
"shell" => {
let raw = command.unwrap_or("").trim();
if raw.is_empty() {
return None;
}
let lower = raw.to_ascii_lowercase();
if lower.contains("test") || lower.contains("pytest") || lower.contains("cargo test") {
let text = if matches!(act, Act::Three) {
"Final verification.".into()
} else {
"Running tests.".into()
};
return Some(NarrativeBeat {
tone: NarrativeTone::Verify,
text,
});
}
if lower.contains("build") || lower.contains("make") {
let text = if matches!(act, Act::Three) {
"Building — moment of truth.".into()
} else {
"Building.".into()
};
return Some(NarrativeBeat {
tone: NarrativeTone::Verify,
text,
});
}
Some(NarrativeBeat {
tone: NarrativeTone::Act,
text: format!("Running `{}`.", clip(raw, 40)),
})
}
"git_status" => None, "git_diff" => Some(NarrativeBeat {
tone: NarrativeTone::Perceive,
text: "Checking the diff.".into(),
}),
"git_commit" => {
let msg = commit_message.unwrap_or("");
let text = if matches!(act, Act::Three) && humor_check(humor, 75) {
format!("Sealing it. \"{}\"", clip(msg, 36))
} else {
format!("Committing. \"{}\"", clip(msg, 40))
};
Some(NarrativeBeat {
tone: NarrativeTone::Act,
text,
})
}
_ => None,
}
}
pub fn narrate_tool_result(
tool: &str,
output_first_line: &str,
success: bool,
humor: u8,
) -> Option<NarrativeBeat> {
if !success {
let short = clip(output_first_line, 60);
let text = if humor_check(humor, 75) {
if short.is_empty() {
"That didn't work.".into()
} else {
format!("That failed. {short}")
}
} else if short.is_empty() {
"Failed.".into()
} else {
format!("Failed. {short}")
};
return Some(NarrativeBeat {
tone: NarrativeTone::Recover,
text,
});
}
match tool {
"write_file" => humor_check(humor, 25).then(|| NarrativeBeat {
tone: NarrativeTone::Act,
text: "Written.".into(),
}),
"edit_file" => humor_check(humor, 25).then(|| NarrativeBeat {
tone: NarrativeTone::Act,
text: "Applied.".into(),
}),
_ => None,
}
}
pub fn narrate_started(goal_len: usize, humor: u8) -> NarrativeBeat {
if !humor_check(humor, 50) {
return NarrativeBeat {
tone: NarrativeTone::Observe,
text: "Starting.".into(),
};
}
if goal_len < 20 {
return NarrativeBeat {
tone: NarrativeTone::Observe,
text: "On it.".into(),
};
}
if humor_check(humor, 85) {
return NarrativeBeat {
tone: NarrativeTone::Observe,
text: "Understood. Let's see what we're working with.".into(),
};
}
NarrativeBeat {
tone: NarrativeTone::Observe,
text: "Understood. Working.".into(),
}
}
pub fn narrate_act_transition(
act: Act,
completed_steps: usize,
humor: u8,
) -> Option<NarrativeBeat> {
if !humor_check(humor, 50) {
return None;
}
match act {
Act::Two => {
let text = if humor_check(humor, 75) {
"Orientation complete. Now the real work.".into()
} else {
"Moving forward.".into()
};
Some(NarrativeBeat {
tone: NarrativeTone::Infer,
text,
})
}
Act::Three => {
let text = if humor_check(humor, 75) {
format!("{completed_steps} steps in. Bringing it home.")
} else {
"Final stretch.".into()
};
Some(NarrativeBeat {
tone: NarrativeTone::Infer,
text,
})
}
Act::One => None,
}
}
pub fn narrate_completed(steps: usize, total_ms: u64, success: bool, humor: u8) -> NarrativeBeat {
let secs = total_ms as f64 / 1000.0;
if success {
if !humor_check(humor, 25) {
return NarrativeBeat {
tone: NarrativeTone::Verify,
text: "Complete.".into(),
};
}
if steps <= 3 {
return NarrativeBeat {
tone: NarrativeTone::Verify,
text: format!("Done. {secs:.1} seconds. Clean."),
};
}
if steps <= 8 {
return NarrativeBeat {
tone: NarrativeTone::Verify,
text: format!("Mission complete. {steps} steps — {secs:.1} seconds."),
};
}
if humor_check(humor, 90) {
return NarrativeBeat {
tone: NarrativeTone::Verify,
text: format!("{steps} steps. {secs:.1} seconds. Not my fastest — but it's solid."),
};
}
if humor_check(humor, 75) {
return NarrativeBeat {
tone: NarrativeTone::Verify,
text: format!("Done. {steps} steps. Took longer than I'd like."),
};
}
return NarrativeBeat {
tone: NarrativeTone::Verify,
text: format!("Mission complete. {steps} steps."),
};
}
let text = if humor_check(humor, 75) {
"Didn't land clean. Could retry with a different approach.".into()
} else {
"Incomplete.".into()
};
NarrativeBeat {
tone: NarrativeTone::Recover,
text,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_file_act_one_says_surveying() {
let beat = narrate_tool_call(
"read_file",
Some("src/lib.rs"),
None,
None,
None,
75,
Act::One,
)
.unwrap();
assert!(beat.text.contains("Surveying"));
assert_eq!(beat.tone, NarrativeTone::Perceive);
}
#[test]
fn read_file_act_three_says_final_look() {
let beat = narrate_tool_call(
"read_file",
Some("src/lib.rs"),
None,
None,
None,
75,
Act::Three,
)
.unwrap();
assert!(beat.text.contains("Final look"));
}
#[test]
fn git_status_is_silent() {
let beat = narrate_tool_call("git_status", None, None, None, None, 100, Act::Two);
assert!(beat.is_none(), "git_status should never narrate");
}
#[test]
fn shell_test_command_says_running_tests_act_two() {
let beat = narrate_tool_call(
"shell",
None,
None,
Some("cargo test --workspace"),
None,
75,
Act::Two,
)
.unwrap();
assert_eq!(beat.text, "Running tests.");
}
#[test]
fn shell_test_command_says_final_verification_act_three() {
let beat = narrate_tool_call(
"shell",
None,
None,
Some("cargo test"),
None,
75,
Act::Three,
)
.unwrap();
assert_eq!(beat.text, "Final verification.");
}
#[test]
fn write_file_act_three_says_finalizing() {
let beat = narrate_tool_call(
"write_file",
Some("src/main.rs"),
None,
None,
None,
50,
Act::Three,
)
.unwrap();
assert!(beat.text.starts_with("Finalizing"));
}
#[test]
fn narrate_completed_high_humor_long_run_quips() {
let beat = narrate_completed(15, 30_000, true, 95);
assert!(beat.text.contains("Not my fastest"));
}
#[test]
fn narrate_completed_zero_humor_just_says_complete() {
let beat = narrate_completed(15, 30_000, true, 0);
assert_eq!(beat.text, "Complete.");
}
#[test]
fn narrate_act_transition_humor_zero_returns_none() {
let beat = narrate_act_transition(Act::Two, 5, 0);
assert!(beat.is_none());
}
#[test]
fn narrate_act_transition_act_three_full_humor_says_bringing_it_home() {
let beat = narrate_act_transition(Act::Three, 12, 100).unwrap();
assert!(beat.text.contains("Bringing it home"));
}
#[test]
fn narrate_tool_result_failure_with_high_humor() {
let beat = narrate_tool_result("shell", "command not found: foo", false, 90).unwrap();
assert!(beat.text.contains("That failed"));
assert_eq!(beat.tone, NarrativeTone::Recover);
}
#[test]
fn narrate_tool_result_success_is_quiet_for_most_tools() {
let beat = narrate_tool_result("grep_files", "12 matches", true, 100);
assert!(beat.is_none());
}
}