use super::*;
use crate::herdr::Handoff;
#[derive(Debug, Clone, PartialEq)]
pub struct Note {
pub about: Option<(String, String)>,
pub body: String,
}
pub const HINT: &str = "`linear-tui context` shows this view with the row under my cursor; \
`linear-tui issue show|comment|status <ID>` and `linear-tui issue create --team <key> --title <text>` \
act on Linear.";
impl App {
pub fn start_note_on_issue(&mut self) {
let about = self
.focused_issue()
.map(|i| (i.identifier.clone(), i.title.clone()));
self.start_note(about);
}
pub fn start_note_on_view(&mut self) {
self.start_note(None);
}
fn start_note(&mut self, about: Option<(String, String)>) {
self.view.note_about = about;
self.view.note.clear();
self.view.input_mode = InputMode::Note;
}
pub fn submit_note(&mut self) {
let body = std::mem::take(&mut self.view.note.value).trim().to_string();
self.view.input_mode = InputMode::Normal;
self.view.note.clear();
if body.is_empty() {
return;
}
self.view.notes.push(Note {
about: self.view.note_about.take(),
body,
});
let count = self.view.notes.len();
let plural = if count == 1 { "" } else { "s" };
self.set_status(format!(
"{count} note{plural} — Ctrl+S sends them to your agent"
));
}
pub fn cancel_note(&mut self) {
self.view.input_mode = InputMode::Normal;
self.view.note.clear();
self.view.note_about = None;
}
pub fn send_notes(&mut self) {
if self.view.notes.is_empty() {
self.set_status("No notes yet — n notes the issue under the cursor, N the whole view");
return;
}
let handoff = self.notes_prompt();
self.view.notes.clear();
if self.herdr {
self.request(Request::Herdr(handoff));
} else {
if let Handoff::Prompt { text, .. } = handoff {
self.outbox.clipboard = Some(text);
}
self.set_status("Notes copied — paste them into your agent");
}
}
pub fn discard_notes(&mut self) {
let count = self.view.notes.len();
self.view.notes.clear();
self.set_status(match count {
0 => "No notes to discard".to_string(),
1 => "Discarded 1 note".to_string(),
n => format!("Discarded {n} notes"),
});
}
pub(super) fn handoff_failed(&mut self, handoff: &Handoff, error: &str) {
let Handoff::Prompt { text, .. } = handoff else {
self.set_error(format!("Could not reach herdr: {error}"));
return;
};
self.outbox.clipboard = Some(text.clone());
self.set_error(format!(
"Could not hand the notes to herdr: {error}\n\nThey are on the clipboard instead."
));
}
pub fn notes_prompt(&self) -> Handoff {
let notes: Vec<String> = self
.view
.notes
.iter()
.map(|note| {
let about = match ¬e.about {
Some((identifier, title)) => format!("**{identifier}** {title}"),
None => "**This view**".to_string(),
};
let body = note.body.replace('\n', "\n ");
format!("- {about}: {body}")
})
.collect();
let notes = notes.join("\n");
let view = self.view_label();
let text = format!(
"My notes on what I am looking at in linear-tui ({view}):\n\n{notes}\n\n({HINT})"
);
Handoff::Prompt {
text,
notes,
view,
hint: HINT.to_string(),
}
}
pub fn view_label(&self) -> String {
let team = |index: usize| {
self.store
.teams
.get(index)
.map_or_else(|| "Team".to_string(), |t| t.name.clone())
};
let mut parts = vec![match self.nav.dest {
Nav::MyIssues => "My Issues".to_string(),
Nav::Views => "Views".to_string(),
Nav::View(i) => self
.store
.custom_views
.get(i)
.map_or_else(|| "View".to_string(), |v| format!("view “{}”", v.name)),
Nav::Team(i, section) => format!(
"{} › {}",
team(i),
match section {
TeamSection::Issues => "Issues",
TeamSection::Cycles => "Cycles",
TeamSection::Projects => "Projects",
TeamSection::Views => "Views",
}
),
Nav::Favorite(_) => "Favorites".to_string(),
}];
let behind = match self.nav.screen {
Screen::IssueDetail => self.nav.detail_return,
screen => screen,
};
if behind == Screen::ProjectDetail
&& let Some(project) = &self.nav.current_project
{
parts.push(project.name.clone());
}
if behind == Screen::CycleDetail
&& let Some(cycle) = &self.nav.current_cycle
{
parts.push(cycle.label());
}
if self.nav.screen == Screen::IssueDetail
&& let Some(issue) = &self.store.current_issue
{
parts.push(issue.identifier.clone());
}
parts.join(" › ")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> App {
let mut app = App::new(&Config::default());
app.store.teams =
vec![serde_json::from_str(r#"{"id":"t","name":"Engineering","key":"ENG"}"#).unwrap()];
app.store.issues[IssueSource::Team].items = vec![
serde_json::from_str(
r#"{"id":"i1","identifier":"ENG-42","title":"Checkout fails","priority":0}"#,
)
.unwrap(),
];
app.store.issues[IssueSource::Team].loaded = true;
app.outbox.requests.clear();
app
}
fn type_note(app: &mut App, text: &str) {
for c in text.chars() {
app.view.note.insert(c);
}
app.submit_note();
}
#[test]
fn notes_name_their_issue_or_the_view_and_end_with_the_hint() {
let mut app = app();
app.start_note_on_issue();
type_note(&mut app, "reproduce first\nthen fix");
app.start_note_on_view();
type_note(&mut app, "the top three are one bug");
let Handoff::Prompt {
text, notes, view, ..
} = app.notes_prompt()
else {
panic!("notes make a prompt");
};
assert_eq!(view, "Engineering › Issues");
assert_eq!(
notes,
"- **ENG-42** Checkout fails: reproduce first\n then fix\n\
- **This view**: the top three are one bug"
);
assert!(
text.starts_with(
"My notes on what I am looking at in linear-tui (Engineering › Issues):"
)
);
assert!(text.ends_with(&format!("({HINT})")));
}
#[test]
fn an_empty_note_is_dropped_and_escape_keeps_nothing() {
let mut app = app();
app.start_note_on_view();
type_note(&mut app, " ");
app.start_note_on_view();
app.view.note.insert('x');
app.cancel_note();
assert!(app.view.notes.is_empty());
assert_eq!(app.view.input_mode, InputMode::Normal);
}
#[test]
fn outside_herdr_the_notes_go_to_the_clipboard() {
let mut app = app();
app.herdr = false;
app.send_notes();
assert!(app.outbox.clipboard.is_none(), "nothing to send yet");
app.start_note_on_issue();
type_note(&mut app, "look");
app.send_notes();
assert!(
app.outbox
.clipboard
.as_deref()
.is_some_and(|t| t.contains("ENG-42"))
);
assert!(app.view.notes.is_empty());
assert!(app.outbox.requests.is_empty());
}
#[test]
fn inside_herdr_the_notes_are_handed_to_the_plugin() {
let mut app = app();
app.herdr = true;
app.start_note_on_view();
type_note(&mut app, "look");
app.send_notes();
assert!(matches!(
app.outbox.requests.front(),
Some(Request::Herdr(Handoff::Prompt { .. }))
));
assert!(app.outbox.clipboard.is_none());
}
}