Skip to main content

vissue_tui/
app.rs

1//! Board state and key handling. Drawing lives in [`crate::view`].
2
3use std::path::PathBuf;
4
5use ratatui::crossterm::event::KeyCode;
6use ratatui::crossterm::event::KeyEvent;
7use vissue_core::config::Layout;
8use vissue_core::keys::{ActionId, KeyMap};
9use vissue_core::views::{IssueDetail, ListQuery};
10
11use crate::attach::{AttachHooks, AttachOutcome, ServeStatus, try_attach};
12use crate::backend::{BoardBackend, ListPage, UpdateReq};
13use crate::core_backend::CoreBackend;
14use crate::keys::{
15    Action, ConfirmKind, DetailTab, Focus, Pane, PromptKind, char_of, chord_of, help_text, is_press,
16};
17
18/// One displayed row. Every pane maps onto this shape so keys share a path.
19#[derive(Debug, Clone)]
20pub struct BoardRow {
21    /// Issue id shown in the first column.
22    pub id: String,
23    /// Org TODO state (`TODO`, `STARTED`, ...).
24    pub state: String,
25    /// Priority letter (`A`, `B`, or `C`).
26    pub priority: String,
27    /// Heading title.
28    pub title: String,
29    /// Project name.
30    pub project: String,
31    /// Pane-specific suffix: holder, agenda date, or search snippet.
32    pub extra: String,
33}
34
35/// Interactive board. Talks only to [`BoardBackend`].
36#[derive(Debug)]
37pub struct App {
38    backend: Box<dyn BoardBackend>,
39    agent: String,
40    status: ServeStatus,
41    message: String,
42    /// Active list pane.
43    pub pane: Pane,
44    /// Detail pane tab (show / excerpt / tree / related).
45    pub detail_tab: DetailTab,
46    /// Whether keys target the row list or the detail pane.
47    pub focus: Focus,
48    /// Rows for the current pane.
49    pub rows: Vec<BoardRow>,
50    /// Index into [`Self::rows`].
51    pub selected: usize,
52    /// Project filter, if any.
53    pub project: Option<String>,
54    /// Known project names for the `p` prompt.
55    pub projects: Vec<String>,
56    /// Last loaded issue detail, if any.
57    pub detail: Option<IssueDetail>,
58    /// Text drawn in the detail pane.
59    pub detail_body: String,
60    /// Open line prompt and its buffer.
61    pub prompt: Option<(PromptKind, String)>,
62    /// Pending DONE/CANCELLED confirmation.
63    pub confirm: Option<ConfirmKind>,
64    /// Help overlay is visible.
65    pub help: bool,
66    /// Last id copied with `y`.
67    pub clipboard: String,
68    search_query: String,
69    /// The shared key catalog as bound on this machine.
70    keymap: KeyMap,
71}
72
73impl App {
74    /// Open a file-backed board and load the Ready pane.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the vault cannot be parsed or the first pane cannot
79    /// be loaded.
80    pub fn open_core(layout: Layout, agent: String) -> Result<Self, vissue_core::error::Error> {
81        let backend = CoreBackend::open(layout, agent.clone())?;
82        Self::with_backend(Box::new(backend), agent, ServeStatus::Offline)
83    }
84
85    /// Build a board around an existing backend and load the Ready pane.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the first pane cannot be loaded.
90    pub fn with_backend(
91        backend: Box<dyn BoardBackend>,
92        agent: String,
93        status: ServeStatus,
94    ) -> Result<Self, vissue_core::error::Error> {
95        let projects = backend.projects().unwrap_or_default();
96        let mut app = Self {
97            backend,
98            agent,
99            status,
100            message: String::new(),
101            pane: Pane::Ready,
102            detail_tab: DetailTab::Show,
103            focus: Focus::Rows,
104            rows: Vec::new(),
105            selected: 0,
106            project: None,
107            projects,
108            detail: None,
109            detail_body: String::new(),
110            prompt: None,
111            confirm: None,
112            help: false,
113            clipboard: String::new(),
114            search_query: String::new(),
115            keymap: KeyMap::from_defaults(),
116        };
117        #[cfg(not(test))]
118        {
119            app.keymap = KeyMap::load();
120            if let Some(err) = app.keymap.overlay_error.clone() {
121                app.message = err;
122            }
123        }
124        app.reload()?;
125        Ok(app)
126    }
127
128    /// Bind the shared catalog as `keymap` has it; `?` follows.
129    pub fn set_keymap(&mut self, keymap: KeyMap) {
130        self.keymap = keymap;
131    }
132
133    /// The shared catalog as this board binds it.
134    pub fn keymap(&self) -> &KeyMap {
135        &self.keymap
136    }
137
138    /// How the status line labels the current store.
139    pub fn serve_status(&self) -> ServeStatus {
140        self.status
141    }
142
143    /// Identity used for claims and updates.
144    pub fn agent(&self) -> &str {
145        &self.agent
146    }
147
148    /// Catalog generation from the current backend.
149    pub fn generation(&self) -> u64 {
150        self.backend.generation()
151    }
152
153    /// Serve revision from the current backend. Core is always 0.
154    pub fn revision(&self) -> u64 {
155        self.backend.revision()
156    }
157
158    /// Id of the selected row, if the pane is not empty.
159    pub fn selected_id(&self) -> Option<&str> {
160        self.rows.get(self.selected).map(|r| r.id.as_str())
161    }
162
163    /// Org state of the selected row, if the pane is not empty.
164    pub fn selected_state(&self) -> Option<&str> {
165        self.rows.get(self.selected).map(|r| r.state.as_str())
166    }
167
168    /// The store this board is talking to.
169    pub fn backend(&self) -> &dyn BoardBackend {
170        self.backend.as_ref()
171    }
172
173    /// Swap the store and adopt its identity. Does not reload rows.
174    pub fn replace_backend(&mut self, backend: Box<dyn BoardBackend>, status: ServeStatus) {
175        self.backend = backend;
176        self.status = status;
177        self.agent = self.backend.identity().to_string();
178    }
179
180    /// Post-paint attach. `--offline` never probes the socket.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the pane cannot be reloaded after the attach attempt.
185    pub fn attach(
186        &mut self,
187        socket: &std::path::Path,
188        offline: bool,
189        hooks: &AttachHooks,
190    ) -> Result<(), vissue_core::error::Error> {
191        let layout = self.backend.layout().clone();
192        let agent = self.agent.clone();
193        match try_attach(&layout, socket, &agent, offline, hooks) {
194            AttachOutcome::Switch { backend, status } => {
195                self.replace_backend(backend, status);
196                self.message.clear();
197            }
198            AttachOutcome::Stay { status, message } => {
199                self.status = status;
200                self.message = message;
201            }
202        }
203        self.reload()
204    }
205
206    /// One-line `serve:` / gen / rev / agent / project / message summary.
207    pub fn status_line(&self) -> String {
208        let kind = match self.status {
209            ServeStatus::Live => "live",
210            ServeStatus::Offline => "offline",
211            ServeStatus::Mismatch => "mismatch",
212        };
213        let mut line = format!(
214            "serve:{kind} gen={} rev={} agent={}",
215            self.backend.generation(),
216            self.backend.revision(),
217            self.agent
218        );
219        if let Some(project) = &self.project {
220            line.push_str(" project=");
221            line.push_str(project);
222        }
223        if !self.message.is_empty() {
224            line.push_str("  ");
225            line.push_str(&self.message);
226        }
227        line
228    }
229
230    /// Fetch the current pane from the backend and refresh detail.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if the backend cannot load the pane.
235    pub fn reload(&mut self) -> Result<(), vissue_core::error::Error> {
236        let project = self.project.as_deref();
237        match self.pane {
238            Pane::Ready => self.apply_issue_page(self.backend.ready(project)?),
239            Pane::List => self.apply_issue_page(self.backend.list(ListQuery {
240                project: project.map(str::to_string),
241                ..ListQuery::default()
242            })?),
243            Pane::Claims => {
244                self.rows = self
245                    .backend
246                    .claims(None, project)?
247                    .into_iter()
248                    .map(row_from_claim)
249                    .collect();
250            }
251            Pane::Agenda => {
252                self.rows = self
253                    .backend
254                    .agenda(14, project)?
255                    .into_iter()
256                    .map(row_from_agenda)
257                    .collect();
258            }
259            Pane::Search => {
260                self.rows = if self.search_query.is_empty() {
261                    Vec::new()
262                } else {
263                    self.backend
264                        .search(&self.search_query, 50)?
265                        .into_iter()
266                        .map(row_from_search)
267                        .collect()
268                };
269            }
270        }
271        if self.selected >= self.rows.len() {
272            self.selected = self.rows.len().saturating_sub(1);
273        }
274        self.refresh_detail();
275        Ok(())
276    }
277
278    /// Serve answers `{unchanged: true, issues: []}` when `since_revision`
279    /// matches the catalog. Keep the rows from the last full page.
280    fn apply_issue_page(&mut self, page: ListPage) {
281        if page.unchanged {
282            return;
283        }
284        self.rows = page.issues.into_iter().map(row_from_issue).collect();
285    }
286
287    /// Wait briefly for a catalog change and reload when the watermark moves.
288    pub fn poll_updates(&mut self) {
289        let last = match self.backend.live() {
290            crate::backend::BackendKind::Control => self.backend.revision(),
291            crate::backend::BackendKind::Core => self.backend.generation(),
292        };
293        if let Ok(next) = self.backend.wait(last, 1)
294            && next > last
295        {
296            let _ = self.reload();
297        }
298    }
299
300    /// Dispatch one key. Repeat and press count; release is ignored.
301    pub fn handle_key(&mut self, key: KeyEvent) -> Action {
302        if !is_press(key) {
303            return Action::Continue;
304        }
305        if self.help {
306            if matches!(
307                key.code,
308                KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?')
309            ) {
310                self.help = false;
311            }
312            return Action::Continue;
313        }
314        if self.confirm.is_some() {
315            return self.handle_confirm(key);
316        }
317        if self.prompt.is_some() {
318            return self.handle_prompt(key);
319        }
320        // The shared catalog answers first, as bound: a remap in
321        // keys.toml moves the action, and the old chord does nothing.
322        if let Some(id) = chord_of(key).and_then(|chord| self.keymap.get(&chord)) {
323            return self.handle_action(id);
324        }
325        match key.code {
326            KeyCode::Char('q') => Action::Quit,
327            KeyCode::Esc => {
328                if self.focus == Focus::Detail {
329                    self.focus = Focus::Rows;
330                    Action::Continue
331                } else {
332                    Action::Quit
333                }
334            }
335            KeyCode::Down => {
336                self.move_sel(1);
337                Action::Continue
338            }
339            KeyCode::Up => {
340                self.move_sel(-1);
341                Action::Continue
342            }
343            _ => Action::Continue,
344        }
345    }
346
347    /// One catalog action, as the board performs it. Actions the HUD alone
348    /// answers do nothing here.
349    fn handle_action(&mut self, id: ActionId) -> Action {
350        match id {
351            ActionId::ListDown => self.move_sel(1),
352            ActionId::ListUp => self.move_sel(-1),
353            ActionId::PaneNext => return self.goto_pane(self.pane.next()),
354            ActionId::PaneReady => return self.goto_pane(Pane::Ready),
355            ActionId::PaneList => return self.goto_pane(Pane::List),
356            ActionId::PaneClaims => return self.goto_pane(Pane::Claims),
357            ActionId::PaneAgenda => return self.goto_pane(Pane::Agenda),
358            ActionId::PaneSearch => return self.goto_pane(Pane::Search),
359            ActionId::ListSelect | ActionId::DetailCycle => {
360                if self.focus == Focus::Detail {
361                    self.detail_tab = self.detail_tab.next();
362                } else {
363                    self.focus = Focus::Detail;
364                }
365                self.refresh_detail();
366            }
367            ActionId::ProjectCycle => {
368                self.prompt = Some((
369                    PromptKind::Project,
370                    self.project.clone().unwrap_or_default(),
371                ));
372            }
373            ActionId::Search => {
374                if self.pane != Pane::Search {
375                    self.backend.invalidate_since();
376                    self.pane = Pane::Search;
377                }
378                self.prompt = Some((PromptKind::Search, self.search_query.clone()));
379            }
380            ActionId::Claim => self.claim_selected(),
381            ActionId::Note => {
382                if self.selected_id().is_some() {
383                    self.prompt = Some((PromptKind::Note, String::new()));
384                }
385            }
386            ActionId::Deed => {
387                if self.selected_id().is_some() {
388                    self.prompt = Some((PromptKind::Deed, String::new()));
389                }
390            }
391            ActionId::StateCycle => self.cycle_state(),
392            ActionId::ConfirmDone | ActionId::ListDone => {
393                if self.selected_id().is_some() {
394                    self.confirm = Some(ConfirmKind::Done);
395                    self.message = "confirm DONE? y/n".into();
396                }
397            }
398            ActionId::ConfirmCancel => {
399                if self.selected_id().is_some() {
400                    self.confirm = Some(ConfirmKind::Cancelled);
401                    self.message = "confirm CANCELLED? y/n".into();
402                }
403            }
404            ActionId::Open => self.open_selected(),
405            ActionId::CopyId => {
406                if let Some(id) = self.selected_id().map(str::to_string) {
407                    self.clipboard = id.clone();
408                    self.message = format!("copied {id}");
409                }
410            }
411            ActionId::Reload => {
412                let _ = self.reload();
413                self.message = "reloaded".into();
414            }
415            ActionId::Help => self.help = true,
416            ActionId::Add
417            | ActionId::Palette
418            | ActionId::PreviewToggle
419            | ActionId::PreviewDown
420            | ActionId::PreviewUp
421            | ActionId::WindowPopOut => {}
422        }
423        Action::Continue
424    }
425
426    fn handle_confirm(&mut self, key: KeyEvent) -> Action {
427        let kind = self.confirm.unwrap();
428        match key.code {
429            KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
430                self.confirm = None;
431                self.apply_state(kind.state());
432            }
433            _ => {
434                self.confirm = None;
435                self.message.clear();
436            }
437        }
438        Action::Continue
439    }
440
441    fn handle_prompt(&mut self, key: KeyEvent) -> Action {
442        let Some((kind, mut text)) = self.prompt.take() else {
443            return Action::Continue;
444        };
445        match key.code {
446            KeyCode::Esc => {
447                self.message.clear();
448            }
449            KeyCode::Enter => match kind {
450                PromptKind::Search => {
451                    self.search_query = text;
452                    if self.pane != Pane::Search {
453                        self.backend.invalidate_since();
454                    }
455                    self.pane = Pane::Search;
456                    let _ = self.reload();
457                }
458                PromptKind::Note => {
459                    if let Some(id) = self.selected_id().map(str::to_string) {
460                        match self.backend.note(&id, &text) {
461                            Ok(result) => {
462                                self.message = result.report.trim().to_string();
463                                let _ = self.reload();
464                            }
465                            Err(err) => self.message = err.to_string(),
466                        }
467                    }
468                }
469                PromptKind::Deed => {
470                    // The refusal has to reach the board. A citation that
471                    // resolves to nothing fails in whatever opens it later, in
472                    // another process on another day, so the message belongs on
473                    // the screen of whoever typed it.
474                    if let Some(id) = self.selected_id().map(str::to_string) {
475                        let accession = text.trim().to_string();
476                        if accession.is_empty() {
477                            self.message = "no deed cited".to_string();
478                        } else {
479                            match self.backend.deed(&id, &[accession]) {
480                                Ok(result) => {
481                                    self.message = result.report.trim().to_string();
482                                    let _ = self.reload();
483                                }
484                                Err(err) => self.message = err.to_string(),
485                            }
486                        }
487                    }
488                }
489                PromptKind::Project => {
490                    let trimmed = text.trim();
491                    let next = if trimmed.is_empty() {
492                        None
493                    } else {
494                        Some(trimmed.to_string())
495                    };
496                    if next != self.project {
497                        self.backend.invalidate_since();
498                    }
499                    self.project = next;
500                    let _ = self.reload();
501                }
502            },
503            KeyCode::Backspace => {
504                text.pop();
505                self.prompt = Some((kind, text));
506            }
507            _ => {
508                if let Some(c) = char_of(key) {
509                    text.push(c);
510                }
511                self.prompt = Some((kind, text));
512            }
513        }
514        Action::Continue
515    }
516
517    fn goto_pane(&mut self, pane: Pane) -> Action {
518        if self.pane != pane {
519            self.backend.invalidate_since();
520            self.pane = pane;
521        }
522        let _ = self.reload();
523        Action::Continue
524    }
525
526    fn move_sel(&mut self, delta: i32) {
527        if self.rows.is_empty() {
528            return;
529        }
530        let len = self.rows.len() as i32;
531        let next = (self.selected as i32 + delta).clamp(0, len - 1) as usize;
532        if next != self.selected {
533            self.selected = next;
534            self.refresh_detail();
535        }
536    }
537
538    fn claim_selected(&mut self) {
539        let Some(id) = self.selected_id().map(str::to_string) else {
540            return;
541        };
542        match self.backend.claim(&id, false) {
543            Ok(result) => {
544                self.message = result.report.trim().to_string();
545                let _ = self.reload();
546            }
547            Err(err) => self.message = err.to_string(),
548        }
549    }
550
551    fn cycle_state(&mut self) {
552        let Some(id) = self.selected_id().map(str::to_string) else {
553            return;
554        };
555        let Some(state) = self.selected_state() else {
556            return;
557        };
558        let next = match state {
559            "TODO" => "STARTED",
560            "STARTED" => "BLOCKED",
561            "BLOCKED" => "TODO",
562            _ => {
563                self.message = format!("{id} is {state}; s cycles TODO/STARTED/BLOCKED");
564                return;
565            }
566        };
567        self.apply_state(next);
568    }
569
570    fn apply_state(&mut self, state: &str) {
571        let Some(id) = self.selected_id().map(str::to_string) else {
572            return;
573        };
574        match self.backend.update(UpdateReq {
575            id: id.clone(),
576            state: Some(state.to_string()),
577            if_state: self.selected_state().map(str::to_string),
578            ..UpdateReq::default()
579        }) {
580            Ok(result) => {
581                self.message = result.report.trim().to_string();
582                let _ = self.reload();
583            }
584            Err(err) => self.message = err.to_string(),
585        }
586    }
587
588    fn open_selected(&mut self) {
589        let Some(id) = self.selected_id().map(str::to_string) else {
590            return;
591        };
592        match self.backend.open(&id) {
593            Ok(detail) => {
594                self.detail = Some(detail);
595                self.message = format!("opened {id}");
596                self.refresh_detail();
597            }
598            Err(err) => self.message = err.to_string(),
599        }
600    }
601
602    fn refresh_detail(&mut self) {
603        let Some(id) = self.selected_id().map(str::to_string) else {
604            self.detail = None;
605            self.detail_body.clear();
606            return;
607        };
608        match self.detail_tab {
609            DetailTab::Show => match self.backend.get(&id) {
610                Ok(detail) => {
611                    self.detail_body = format_show(&detail);
612                    self.detail = Some(detail);
613                }
614                Err(err) => self.detail_body = err.to_string(),
615            },
616            DetailTab::Excerpt => match self.backend.excerpt(&id) {
617                Ok(excerpt) => {
618                    let mut text = excerpt.text;
619                    if !text.ends_with('\n') {
620                        text.push('\n');
621                    }
622                    text.push_str("body lives in file; open the range above");
623                    self.detail_body = text;
624                }
625                Err(err) => self.detail_body = err.to_string(),
626            },
627            DetailTab::Tree => match self.backend.tree(&id) {
628                Ok(node) => self.detail_body = format_tree(&node, 0),
629                Err(err) => self.detail_body = err.to_string(),
630            },
631            DetailTab::Related => match self.backend.related(&id, 2, 20) {
632                Ok(hits) => self.detail_body = format_related(&hits),
633                Err(err) => self.detail_body = err.to_string(),
634            },
635            DetailTab::Recall => match self.backend.recall(&id, 1) {
636                Ok(set) => self.detail_body = format_recall(&set),
637                Err(err) => self.detail_body = err.to_string(),
638            },
639        }
640    }
641
642    /// Label and buffer for the open prompt, if any.
643    pub fn prompt_line(&self) -> Option<String> {
644        self.prompt.as_ref().map(|(kind, text)| {
645            let label = match kind {
646                PromptKind::Search => "search",
647                PromptKind::Note => "note",
648                PromptKind::Deed => "deed",
649                PromptKind::Project => "project",
650            };
651            format!("{label}: {text}")
652        })
653    }
654
655    /// Confirmation line for DONE/CANCELLED, if any.
656    pub fn confirm_line(&self) -> Option<String> {
657        self.confirm
658            .map(|kind| format!("confirm {}? y/n", kind.state()))
659    }
660
661    /// Text drawn on `?`: the shared catalog as bound, then the board's own.
662    pub fn help_text(&self) -> String {
663        help_text(&self.keymap)
664    }
665}
666
667fn row_from_issue(row: vissue_core::views::IssueRow) -> BoardRow {
668    let extra = row.claimed_by.unwrap_or_default();
669    BoardRow {
670        id: row.id,
671        state: row.state,
672        priority: row.priority,
673        title: row.title,
674        project: row.project,
675        extra,
676    }
677}
678
679fn row_from_claim(row: vissue_core::views::ClaimRow) -> BoardRow {
680    BoardRow {
681        id: row.id,
682        state: row.state,
683        priority: row.priority,
684        title: row.title,
685        project: row.project,
686        extra: format!("{} {}d", row.holder.unwrap_or_default(), row.age_days),
687    }
688}
689
690fn row_from_agenda(row: vissue_core::views::AgendaRow) -> BoardRow {
691    let extra = match row.kind.as_str() {
692        "deadline" if row.overdue_days > 0 => {
693            format!("deadline {} {}d overdue", row.date, row.overdue_days)
694        }
695        "deadline" => format!("deadline {}", row.date),
696        "scheduled" => format!("scheduled {}", row.date),
697        _ => format!("on {}", row.date),
698    };
699    BoardRow {
700        id: row.id,
701        state: row.state,
702        priority: row.priority,
703        title: row.title,
704        project: row.project,
705        extra,
706    }
707}
708
709fn row_from_search(row: vissue_core::views::SearchHit) -> BoardRow {
710    BoardRow {
711        id: row.id,
712        state: row.state,
713        priority: row.priority,
714        title: row.title,
715        project: row.project,
716        extra: row.snippet,
717    }
718}
719
720fn format_show(d: &IssueDetail) -> String {
721    let mut out = format!(
722        "id: {}\nproject: {}\nstate: {}\npriority: {}\ntitle: {}\nfile: {}\n",
723        d.id, d.project, d.state, d.priority, d.title, d.file
724    );
725    if let Some(parent) = &d.parent {
726        out.push_str(&format!("parent: {parent}\n"));
727    }
728    if !d.blocked_by.is_empty() {
729        out.push_str(&format!("blocked_by: {}\n", d.blocked_by.join(", ")));
730    }
731    if let Some(who) = &d.claimed_by {
732        out.push_str(&format!("claimed_by: {who}\n"));
733    }
734    if !d.tags.is_empty() {
735        out.push_str(&format!("tags: {}\n", d.tags.join(", ")));
736    }
737    out
738}
739
740fn format_tree(node: &vissue_core::views::TreeNode, depth: usize) -> String {
741    let pad = "  ".repeat(depth);
742    let mut out = format!("{pad}{} [{}] {}\n", node.id, node.state, node.title);
743    for child in &node.children {
744        out.push_str(&format_tree(child, depth + 1));
745    }
746    out
747}
748
749fn format_related(hits: &[vissue_core::views::RelatedHit]) -> String {
750    if hits.is_empty() {
751        return "no related issues\n".into();
752    }
753    let mut out = String::new();
754    for hit in hits {
755        out.push_str(&format!(
756            "{} [{}] {}  score={:.2}  {}\n",
757            hit.id,
758            hit.state,
759            hit.title,
760            hit.score,
761            hit.evidence.join(", ")
762        ));
763    }
764    out
765}
766
767/// The working set, laid out for the detail pane.
768///
769/// Narrower than the command line's rendering: the pane is a column beside a
770/// list, so the plan and the inputs are one line each and the deed accessions
771/// hang under the input that produced them.
772fn format_recall(set: &vissue_core::views::Recall) -> String {
773    let mut out = String::new();
774    for step in &set.plan {
775        out.push_str(&format!(
776            "plan {} [{}] {}\n",
777            step.id, step.state, step.title
778        ));
779    }
780    if set.inputs.is_empty() {
781        out.push_str("no declared inputs\n");
782    }
783    for input in &set.inputs {
784        out.push_str(&format!(
785            "{} [{}] {}  ({})\n",
786            input.id, input.state, input.title, input.relation
787        ));
788        for deed in &input.deeds {
789            out.push_str(&format!("  {deed}\n"));
790        }
791        if let Some(note) = &input.last_note {
792            out.push_str(&format!(
793                "  note: {}\n",
794                note.lines().next().unwrap_or_default().trim()
795            ));
796        }
797    }
798    if !set.produced.is_empty() {
799        out.push_str("produced here\n");
800        for deed in &set.produced {
801            out.push_str(&format!("  {deed}\n"));
802        }
803    }
804    out
805}
806
807/// Options for the interactive `vissue tui` entry point.
808#[derive(Debug)]
809pub struct RunOpts {
810    /// Vault root and project prefix.
811    pub layout: Layout,
812    /// Control socket to attach after first paint.
813    pub socket: PathBuf,
814    /// Skip the socket and stay on [`CoreBackend`].
815    pub offline: bool,
816    /// Identity stamped on claims and updates.
817    pub agent: String,
818}
819
820/// First paint via core, then attach unless `--offline`, then the crossterm loop.
821///
822/// # Errors
823///
824/// Returns an error if the vault cannot be opened, the terminal cannot be
825/// installed or drawn, attach reload fails, or a terminal event cannot be read.
826pub fn run(opts: RunOpts) -> Result<(), vissue_core::error::Error> {
827    let mut app = App::open_core(opts.layout.clone(), opts.agent.clone())?;
828    let mut terminal = crate::view::install()?;
829    let result = (|| {
830        terminal.draw(|f| crate::view::draw(f, &app))?;
831        app.attach(&opts.socket, opts.offline, &AttachHooks::default())?;
832        loop {
833            terminal.draw(|f| crate::view::draw(f, &app))?;
834            if ratatui::crossterm::event::poll(std::time::Duration::from_millis(200))? {
835                if let ratatui::crossterm::event::Event::Key(key) =
836                    ratatui::crossterm::event::read()?
837                    && app.handle_key(key) == Action::Quit
838                {
839                    break;
840                }
841            } else {
842                app.poll_updates();
843            }
844        }
845        Ok(())
846    })();
847    crate::view::restore()?;
848    result
849}