Skip to main content

max / makenotwork

wam: Phase 3 — stats/export/prune commands + TUI inline edit CLI: - wam stats: total, by-status, open-by-priority, open-by-source, avg-resolution-time (Xd Yh format) - wam export --format json|csv [--output FILE]: serde JSON; hand-rolled CSV with quote-escaping, no new dep - wam prune --older-than 90d --status closed [--dry-run]: delete by updated_at cutoff; dry-run lists candidates cli.rs gains a parse_duration("90d"|"12h"|"30m") + 6 unit tests. DB layer adds update_ticket, stats, prune_tickets with tests. TUI: new View::Edit reached via 'e' in Detail. Tab toggles Title/Body focus; Enter advances Title -> Body, saves on Body; Esc cancels. Saves via db::update_ticket. 10 new tests (17 total). Status: Phase 3 partial (4 of 8 items landed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-25 19:56 UTC
Commit: 0dcd28d278faf75df1c1882f6794656c8c7cc003
Parent: 7458e2c
4 files changed, +554 insertions, -4 deletions
@@ -73,4 +73,90 @@ pub enum Command {
73 73 #[arg(long)]
74 74 peer: Vec<String>,
75 75 },
76 + /// Show ticket aggregates: open by priority/source + avg resolution time
77 + Stats,
78 + /// Dump tickets as JSON or CSV
79 + Export {
80 + /// Output format
81 + #[arg(long, default_value = "json")]
82 + format: ExportFormat,
83 + /// Output file. Defaults to stdout.
84 + #[arg(short, long)]
85 + output: Option<std::path::PathBuf>,
86 + },
87 + /// Delete tickets matching the given status that have been untouched for
88 + /// at least `--older-than`. Duration format: `90d`, `12h`, `30m`.
89 + Prune {
90 + /// Age threshold (e.g. `90d`, `12h`, `30m`)
91 + #[arg(long)]
92 + older_than: String,
93 + /// Status to prune (defaults to closed)
94 + #[arg(long, default_value = "closed")]
95 + status: Status,
96 + /// Print what would be deleted without modifying the database
97 + #[arg(long)]
98 + dry_run: bool,
99 + },
100 + }
101 +
102 + #[derive(Clone, Copy, Debug, clap::ValueEnum)]
103 + pub enum ExportFormat {
104 + Json,
105 + Csv,
106 + }
107 +
108 + /// Parse a duration string like `90d`, `12h`, `30m` into `chrono::Duration`.
109 + pub fn parse_duration(s: &str) -> Result<chrono::Duration, String> {
110 + let s = s.trim();
111 + if s.is_empty() {
112 + return Err("duration is empty".to_string());
113 + }
114 + let (num, unit) = s.split_at(s.len() - 1);
115 + let n: i64 = num
116 + .parse()
117 + .map_err(|_| format!("invalid duration number in '{s}'"))?;
118 + if n < 0 {
119 + return Err(format!("duration must be non-negative: '{s}'"));
120 + }
121 + match unit {
122 + "d" => Ok(chrono::Duration::days(n)),
123 + "h" => Ok(chrono::Duration::hours(n)),
124 + "m" => Ok(chrono::Duration::minutes(n)),
125 + other => Err(format!("unknown duration unit '{other}' (use d/h/m)")),
126 + }
127 + }
128 +
129 + #[cfg(test)]
130 + mod tests {
131 + use super::*;
132 +
133 + #[test]
134 + fn parse_duration_days() {
135 + assert_eq!(parse_duration("90d").unwrap(), chrono::Duration::days(90));
136 + }
137 +
138 + #[test]
139 + fn parse_duration_hours() {
140 + assert_eq!(parse_duration("12h").unwrap(), chrono::Duration::hours(12));
141 + }
142 +
143 + #[test]
144 + fn parse_duration_minutes() {
145 + assert_eq!(parse_duration("30m").unwrap(), chrono::Duration::minutes(30));
146 + }
147 +
148 + #[test]
149 + fn parse_duration_rejects_unknown_unit() {
150 + assert!(parse_duration("5y").is_err());
151 + }
152 +
153 + #[test]
154 + fn parse_duration_rejects_negative() {
155 + assert!(parse_duration("-1d").is_err());
156 + }
157 +
158 + #[test]
159 + fn parse_duration_rejects_empty() {
160 + assert!(parse_duration("").is_err());
161 + }
76 162 }
M wam/src/db.rs +175
@@ -239,6 +239,102 @@ pub fn update_status(conn: &Connection, id: &str, status: Status) -> Result<()>
239 239 Ok(())
240 240 }
241 241
242 + /// Update a ticket's title and body. Bumps `updated_at`.
243 + pub fn update_ticket(
244 + conn: &Connection,
245 + id: &str,
246 + title: &str,
247 + body: Option<&str>,
248 + ) -> Result<()> {
249 + let now = Utc::now().to_rfc3339();
250 + let rows = conn.execute(
251 + "UPDATE tickets SET title = ?1, body = ?2, updated_at = ?3 WHERE id = ?4",
252 + params![title, body, now, id],
253 + )?;
254 + if rows == 0 {
255 + return Err(eyre!("no ticket with id '{id}'"));
256 + }
257 + Ok(())
258 + }
259 +
260 + /// Aggregate stats across all tickets.
261 + #[derive(Debug, Default)]
262 + pub struct Stats {
263 + pub total: usize,
264 + pub by_status: Vec<(Status, usize)>,
265 + pub open_by_priority: Vec<(Priority, usize)>,
266 + pub open_by_source: Vec<(String, usize)>,
267 + /// Average seconds between `created_at` and `resolved_at` for tickets that
268 + /// were resolved. `None` when nothing has been resolved yet.
269 + pub avg_resolution_seconds: Option<i64>,
270 + }
271 +
272 + pub fn stats(conn: &Connection) -> Result<Stats> {
273 + let tickets = list_tickets(conn, &ListFilter::default())?;
274 + let mut s = Stats { total: tickets.len(), ..Default::default() };
275 +
276 + let order_status = [Status::Open, Status::InProgress, Status::Resolved, Status::Closed];
277 + for status in order_status {
278 + let count = tickets.iter().filter(|t| t.status == status).count();
279 + if count > 0 {
280 + s.by_status.push((status, count));
281 + }
282 + }
283 +
284 + let order_pri = [Priority::Critical, Priority::High, Priority::Medium, Priority::Low];
285 + for pri in order_pri {
286 + let count = tickets
287 + .iter()
288 + .filter(|t| t.status == Status::Open && t.priority == pri)
289 + .count();
290 + if count > 0 {
291 + s.open_by_priority.push((pri, count));
292 + }
293 + }
294 +
295 + let mut by_source: std::collections::HashMap<String, usize> =
296 + std::collections::HashMap::new();
297 + for t in tickets.iter().filter(|t| t.status == Status::Open) {
298 + let key = t.source.clone().unwrap_or_else(|| "-".to_string());
299 + *by_source.entry(key).or_insert(0) += 1;
300 + }
301 + let mut by_source: Vec<(String, usize)> = by_source.into_iter().collect();
302 + by_source.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
303 + s.open_by_source = by_source;
304 +
305 + let resolved: Vec<&Ticket> = tickets
306 + .iter()
307 + .filter(|t| t.resolved_at.is_some())
308 + .collect();
309 + if !resolved.is_empty() {
310 + let total: i64 = resolved
311 + .iter()
312 + .map(|t| {
313 + let resolved_at = t.resolved_at.unwrap();
314 + (resolved_at - t.created_at).num_seconds().max(0)
315 + })
316 + .sum();
317 + s.avg_resolution_seconds = Some(total / resolved.len() as i64);
318 + }
319 +
320 + Ok(s)
321 + }
322 +
323 + /// Delete tickets older than `older_than` with the given status.
324 + /// Returns the number of rows deleted.
325 + pub fn prune_tickets(
326 + conn: &Connection,
327 + older_than: chrono::Duration,
328 + status: Status,
329 + ) -> Result<usize> {
330 + let cutoff = (Utc::now() - older_than).to_rfc3339();
331 + let rows = conn.execute(
332 + "DELETE FROM tickets WHERE status = ?1 AND updated_at < ?2",
333 + params![status.to_string(), cutoff],
334 + )?;
335 + Ok(rows)
336 + }
337 +
242 338 // -- Sync operations ----------------------------------------------------------
243 339
244 340 /// Get all tickets updated after the given timestamp.
@@ -449,6 +545,85 @@ mod tests {
449 545 }
450 546
451 547 #[test]
548 + fn update_ticket_changes_fields() {
549 + let conn = open_memory().unwrap();
550 + let node = get_or_create_node_id(&conn).unwrap();
551 + let t = create_ticket(&conn, &test_new_ticket("orig"), &node).unwrap();
552 +
553 + update_ticket(&conn, &t.id, "renamed", Some("with body")).unwrap();
554 + let fetched = get_ticket(&conn, &t.id).unwrap();
555 + assert_eq!(fetched.title, "renamed");
556 + assert_eq!(fetched.body.as_deref(), Some("with body"));
557 + }
558 +
559 + #[test]
560 + fn update_ticket_missing_errors() {
561 + let conn = open_memory().unwrap();
562 + assert!(update_ticket(&conn, "no-such-id", "x", None).is_err());
563 + }
564 +
565 + #[test]
566 + fn stats_aggregates() {
567 + let conn = open_memory().unwrap();
568 + let node = get_or_create_node_id(&conn).unwrap();
569 + let a = create_ticket(&conn, &NewTicket {
570 + title: "a".into(), body: None, priority: Priority::Critical,
571 + channel: Channel::System, source: Some("pom".into()), source_ref: None,
572 + }, &node).unwrap();
573 + create_ticket(&conn, &NewTicket {
574 + title: "b".into(), body: None, priority: Priority::Low,
575 + channel: Channel::Task, source: Some("manual".into()), source_ref: None,
576 + }, &node).unwrap();
577 + let c = create_ticket(&conn, &test_new_ticket("c"), &node).unwrap();
578 + update_status(&conn, &c.id, Status::Resolved).unwrap();
579 +
580 + let s = stats(&conn).unwrap();
581 + assert_eq!(s.total, 3);
582 + assert!(s.by_status.iter().any(|(st, n)| *st == Status::Open && *n == 2));
583 + assert!(s.by_status.iter().any(|(st, n)| *st == Status::Resolved && *n == 1));
584 + // Two open tickets: Critical and Low
585 + assert!(s.open_by_priority.iter().any(|(p, n)| *p == Priority::Critical && *n == 1));
586 + assert!(s.open_by_priority.iter().any(|(p, n)| *p == Priority::Low && *n == 1));
587 + // Source counts only the open tickets
588 + let pom = s.open_by_source.iter().find(|(k, _)| k == "pom").map(|(_, n)| *n);
589 + assert_eq!(pom, Some(1));
590 + assert!(s.avg_resolution_seconds.is_some());
591 + // First ticket is still open
592 + let _ = a;
593 + }
594 +
595 + #[test]
596 + fn prune_deletes_matching() {
597 + let conn = open_memory().unwrap();
598 + let node = get_or_create_node_id(&conn).unwrap();
599 + let closed = create_ticket(&conn, &test_new_ticket("old closed"), &node).unwrap();
600 + update_status(&conn, &closed.id, Status::Closed).unwrap();
601 + // Backdate updated_at by 100 days
602 + let backdate = (Utc::now() - chrono::Duration::days(100)).to_rfc3339();
603 + conn.execute(
604 + "UPDATE tickets SET updated_at = ?1 WHERE id = ?2",
605 + params![backdate, closed.id],
606 + ).unwrap();
607 +
608 + // A recent closed ticket should not be pruned
609 + let recent = create_ticket(&conn, &test_new_ticket("recent closed"), &node).unwrap();
610 + update_status(&conn, &recent.id, Status::Closed).unwrap();
611 +
612 + // An old but still-open ticket should not be pruned
613 + let open = create_ticket(&conn, &test_new_ticket("old open"), &node).unwrap();
614 + conn.execute(
615 + "UPDATE tickets SET updated_at = ?1 WHERE id = ?2",
616 + params![backdate, open.id],
617 + ).unwrap();
618 +
619 + let n = prune_tickets(&conn, chrono::Duration::days(90), Status::Closed).unwrap();
620 + assert_eq!(n, 1);
621 + assert!(get_ticket(&conn, &closed.id).is_err());
622 + assert!(get_ticket(&conn, &recent.id).is_ok());
623 + assert!(get_ticket(&conn, &open.id).is_ok());
624 + }
625 +
626 + #[test]
452 627 fn node_id_persists() {
453 628 let conn = open_memory().unwrap();
454 629 let id1 = get_or_create_node_id(&conn).unwrap();
M wam/src/main.rs +131 -2
@@ -4,9 +4,11 @@ mod db;
4 4 mod tui;
5 5 mod types;
6 6
7 + use std::io::Write;
8 +
7 9 use clap::Parser;
8 - use cli::Command;
9 - use color_eyre::eyre::Result;
10 + use cli::{Command, ExportFormat};
11 + use color_eyre::eyre::{Result, WrapErr};
10 12 use db::ListFilter;
11 13 use types::{NewTicket, Status};
12 14
@@ -97,11 +99,138 @@ fn main() -> Result<()> {
97 99 let rt = tokio::runtime::Runtime::new()?;
98 100 rt.block_on(api::serve(conn, port, peer))?;
99 101 }
102 +
103 + Some(Command::Stats) => print_stats(&conn)?,
104 +
105 + Some(Command::Export { format, output }) => {
106 + let tickets = db::list_tickets(&conn, &ListFilter::default())?;
107 + let mut writer: Box<dyn Write> = match output {
108 + Some(path) => Box::new(
109 + std::fs::File::create(&path)
110 + .wrap_err_with(|| format!("create {}", path.display()))?,
111 + ),
112 + None => Box::new(std::io::stdout().lock()),
113 + };
114 + match format {
115 + ExportFormat::Json => {
116 + serde_json::to_writer_pretty(&mut writer, &tickets)?;
117 + writeln!(writer)?;
118 + }
119 + ExportFormat::Csv => write_csv(&mut writer, &tickets)?,
120 + }
121 + }
122 +
123 + Some(Command::Prune { older_than, status, dry_run }) => {
124 + let dur = cli::parse_duration(&older_than)
125 + .map_err(|e| color_eyre::eyre::eyre!(e))?;
126 + if dry_run {
127 + let cutoff = chrono::Utc::now() - dur;
128 + let candidates: Vec<_> = db::list_tickets(&conn, &ListFilter {
129 + status: Some(status),
130 + ..Default::default()
131 + })?
132 + .into_iter()
133 + .filter(|t| t.updated_at < cutoff)
134 + .collect();
135 + if candidates.is_empty() {
136 + println!("no tickets to prune");
137 + } else {
138 + for t in &candidates {
139 + println!("{} {} ({})", t.short_id(), t.title, t.age());
140 + }
141 + println!("\nwould delete {} ticket(s)", candidates.len());
142 + }
143 + } else {
144 + let n = db::prune_tickets(&conn, dur, status)?;
145 + println!("pruned {n} ticket(s)");
146 + }
147 + }
148 + }
149 +
150 + Ok(())
151 + }
152 +
153 + fn print_stats(conn: &rusqlite::Connection) -> Result<()> {
154 + let s = db::stats(conn)?;
155 + println!("Total: {}", s.total);
156 +
157 + if !s.by_status.is_empty() {
158 + println!("\nBy status:");
159 + for (st, n) in &s.by_status {
160 + println!(" {st:<12} {n}");
161 + }
162 + }
163 +
164 + if !s.open_by_priority.is_empty() {
165 + println!("\nOpen by priority:");
166 + for (p, n) in &s.open_by_priority {
167 + println!(" {p:<9} {n}");
168 + }
100 169 }
101 170
171 + if !s.open_by_source.is_empty() {
172 + println!("\nOpen by source:");
173 + for (src, n) in &s.open_by_source {
174 + println!(" {src:<20} {n}");
175 + }
176 + }
177 +
178 + if let Some(avg) = s.avg_resolution_seconds {
179 + println!("\nAverage resolution time: {}", format_duration(avg));
180 + } else {
181 + println!("\nAverage resolution time: -");
182 + }
183 + Ok(())
184 + }
185 +
186 + fn format_duration(secs: i64) -> String {
187 + let d = secs / 86_400;
188 + let h = (secs % 86_400) / 3_600;
189 + let m = (secs % 3_600) / 60;
190 + if d > 0 {
191 + format!("{d}d {h}h")
192 + } else if h > 0 {
193 + format!("{h}h {m}m")
194 + } else {
195 + format!("{m}m")
196 + }
197 + }
198 +
199 + fn write_csv<W: Write>(w: &mut W, tickets: &[types::Ticket]) -> Result<()> {
200 + writeln!(
201 + w,
202 + "id,title,body,priority,status,channel,node_id,source,source_ref,created_at,updated_at,resolved_at"
203 + )?;
204 + for t in tickets {
205 + writeln!(
206 + w,
207 + "{},{},{},{},{},{},{},{},{},{},{},{}",
208 + csv_escape(&t.id),
209 + csv_escape(&t.title),
210 + csv_escape(t.body.as_deref().unwrap_or("")),
211 + t.priority,
212 + t.status,
213 + t.channel,
214 + csv_escape(&t.node_id),
215 + csv_escape(t.source.as_deref().unwrap_or("")),
216 + csv_escape(t.source_ref.as_deref().unwrap_or("")),
217 + t.created_at.to_rfc3339(),
218 + t.updated_at.to_rfc3339(),
219 + t.resolved_at.map(|d| d.to_rfc3339()).unwrap_or_default(),
220 + )?;
221 + }
102 222 Ok(())
103 223 }
104 224
225 + fn csv_escape(s: &str) -> String {
226 + if s.contains(',') || s.contains('"') || s.contains('\n') {
227 + let escaped = s.replace('"', "\"\"");
228 + format!("\"{escaped}\"")
229 + } else {
230 + s.to_string()
231 + }
232 + }
233 +
105 234 fn truncate(s: &str, max: usize) -> String {
106 235 if s.len() <= max {
107 236 s.to_string()
M wam/src/tui.rs +162 -2
@@ -20,6 +20,20 @@ enum View {
20 20 List,
21 21 Detail,
22 22 Create,
23 + Edit,
24 + }
25 +
26 + #[derive(Clone, Copy, PartialEq, Eq)]
27 + enum EditFocus {
28 + Title,
29 + Body,
30 + }
31 +
32 + struct EditBuf {
33 + id: String,
34 + title: String,
35 + body: String,
36 + focus: EditFocus,
23 37 }
24 38
25 39 enum InputMode {
@@ -43,6 +57,7 @@ struct App {
43 57 all_sources: Vec<String>,
44 58 search_query: String,
45 59 create_input: String,
60 + edit_buf: Option<EditBuf>,
46 61 running: bool,
47 62 }
48 63
@@ -62,6 +77,7 @@ impl App {
62 77 all_sources: Vec::new(),
63 78 search_query: String::new(),
64 79 create_input: String::new(),
80 + edit_buf: None,
65 81 running: true,
66 82 };
67 83 app.reload_sources()?;
@@ -174,6 +190,31 @@ impl App {
174 190 self.refresh()
175 191 }
176 192
193 + fn enter_edit(&mut self) {
194 + if let Some(t) = self.selected_ticket() {
195 + self.edit_buf = Some(EditBuf {
196 + id: t.id.clone(),
197 + title: t.title.clone(),
198 + body: t.body.clone().unwrap_or_default(),
199 + focus: EditFocus::Title,
200 + });
201 + self.view = View::Edit;
202 + }
203 + }
204 +
205 + fn submit_edit(&mut self) -> Result<()> {
206 + if let Some(buf) = self.edit_buf.take() {
207 + let title = buf.title.trim();
208 + if !title.is_empty() {
209 + let body = if buf.body.is_empty() { None } else { Some(buf.body.as_str()) };
210 + db::update_ticket(&self.conn, &buf.id, title, body)?;
211 + self.refresh()?;
212 + }
213 + }
214 + self.view = View::Detail;
215 + Ok(())
216 + }
217 +
177 218 fn submit_create(&mut self) -> Result<()> {
178 219 let title = self.create_input.trim().to_string();
179 220 if !title.is_empty() {
@@ -262,6 +303,52 @@ fn handle_key(app: &mut App, key: KeyCode) -> Result<()> {
262 303 return Ok(());
263 304 }
264 305
306 + // Edit input mode
307 + if matches!(app.view, View::Edit) {
308 + match key {
309 + KeyCode::Esc => {
310 + app.edit_buf = None;
311 + app.view = View::Detail;
312 + }
313 + KeyCode::Tab | KeyCode::BackTab => {
314 + if let Some(buf) = app.edit_buf.as_mut() {
315 + buf.focus = match buf.focus {
316 + EditFocus::Title => EditFocus::Body,
317 + EditFocus::Body => EditFocus::Title,
318 + };
319 + }
320 + }
321 + KeyCode::Enter => {
322 + if let Some(buf) = app.edit_buf.as_mut() {
323 + match buf.focus {
324 + EditFocus::Title => buf.focus = EditFocus::Body,
325 + EditFocus::Body => {
326 + app.submit_edit()?;
327 + }
328 + }
329 + }
330 + }
331 + KeyCode::Backspace => {
332 + if let Some(buf) = app.edit_buf.as_mut() {
333 + match buf.focus {
334 + EditFocus::Title => { buf.title.pop(); }
335 + EditFocus::Body => { buf.body.pop(); }
336 + }
337 + }
338 + }
339 + KeyCode::Char(c) => {
340 + if let Some(buf) = app.edit_buf.as_mut() {
341 + match buf.focus {
342 + EditFocus::Title => buf.title.push(c),
343 + EditFocus::Body => buf.body.push(c),
344 + }
345 + }
346 + }
347 + _ => {}
348 + }
349 + return Ok(());
350 + }
351 +
265 352 match app.view {
266 353 View::List => match key {
267 354 KeyCode::Char('q') => app.running = false,
@@ -304,9 +391,10 @@ fn handle_key(app: &mut App, key: KeyCode) -> Result<()> {
304 391 KeyCode::Char('i') => { app.set_selected_status(Status::InProgress)?; }
305 392 KeyCode::Char('r') => { app.set_selected_status(Status::Resolved)?; }
306 393 KeyCode::Char('c') => { app.set_selected_status(Status::Closed)?; }
394 + KeyCode::Char('e') => app.enter_edit(),
307 395 _ => {}
308 396 },
309 - View::Create => {} // handled above
397 + View::Create | View::Edit => {} // handled above
310 398 }
311 399
312 400 Ok(())
@@ -331,6 +419,10 @@ fn render(app: &mut App, f: &mut Frame) {
331 419 render_list(app, f, main_area);
332 420 render_create_popup(app, f, f.area());
333 421 }
422 + View::Edit => {
423 + render_detail(app, f, main_area);
424 + render_edit_popup(app, f, f.area());
425 + }
334 426 }
335 427
336 428 render_status_bar(app, f, status_area);
@@ -518,14 +610,82 @@ fn render_create_popup(app: &App, f: &mut Frame, area: Rect) {
518 610 f.render_widget(Paragraph::new(lines), inner);
519 611 }
520 612
613 + fn render_edit_popup(app: &App, f: &mut Frame, area: Rect) {
614 + let buf = match app.edit_buf.as_ref() {
615 + Some(b) => b,
616 + None => return,
617 + };
618 +
619 + let popup_width = 70.min(area.width.saturating_sub(4));
620 + let popup_height = 10.min(area.height.saturating_sub(4));
621 + let popup_area = Rect {
622 + x: (area.width.saturating_sub(popup_width)) / 2,
623 + y: (area.height.saturating_sub(popup_height)) / 2,
624 + width: popup_width,
625 + height: popup_height,
626 + };
627 +
628 + f.render_widget(Clear, popup_area);
629 +
630 + let block = Block::default()
631 + .borders(Borders::ALL)
632 + .title(" Edit Ticket ");
633 + let inner = block.inner(popup_area);
634 + f.render_widget(block, popup_area);
635 +
636 + let title_focused = buf.focus == EditFocus::Title;
637 + let body_focused = buf.focus == EditFocus::Body;
638 + let cursor = "_";
639 +
640 + let mut lines = vec![
641 + Line::from(vec![
642 + Span::styled(if title_focused { "> Title: " } else { " Title: " },
643 + Style::new().bold()),
644 + Span::styled(&buf.title, Style::new().underlined()),
645 + if title_focused {
646 + Span::styled(cursor, Style::new().rapid_blink())
647 + } else {
648 + Span::raw("")
649 + },
650 + ]),
651 + Line::raw(""),
652 + Line::from(vec![
653 + Span::styled(if body_focused { "> Body: " } else { " Body: " },
654 + Style::new().bold()),
655 + ]),
656 + ];
657 + let body_display = if buf.body.is_empty() && !body_focused {
658 + Line::styled(" (empty)", Style::new().dim())
659 + } else {
660 + Line::from(vec![
661 + Span::raw(" "),
662 + Span::raw(&buf.body),
663 + if body_focused {
664 + Span::styled(cursor, Style::new().rapid_blink())
665 + } else {
666 + Span::raw("")
667 + },
668 + ])
669 + };
670 + lines.push(body_display);
671 + lines.push(Line::raw(""));
672 + lines.push(Line::styled(
673 + "Tab: switch field Enter: next/save Esc: cancel",
674 + Style::new().dim(),
675 + ));
676 +
677 + f.render_widget(Paragraph::new(lines), inner);
678 + }
679 +
521 680 fn render_status_bar(app: &App, f: &mut Frame, area: Rect) {
522 681 let hints = match app.view {
523 682 View::List => match app.input_mode {
524 683 InputMode::Search => "Enter: apply Esc: clear Type to search",
525 684 InputMode::Normal => "j/k:nav Enter:open n:new o/i/r/c:status f:status p:pri t:channel s:src /:search q:quit",
526 685 },
527 - View::Detail => "o/i/r/c:status Esc:back",
686 + View::Detail => "o/i/r/c:status e:edit Esc:back",
528 687 View::Create => "Enter:create Esc:cancel",
688 + View::Edit => "Tab:switch Enter:next/save Esc:cancel",
529 689 };
530 690 f.render_widget(
531 691 Line::from(Span::styled(format!(" {hints}"), Style::new().dim())),