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