linear-tui 0.13.0

A TUI client for Linear.app — manage issues, projects, and cycles from your terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! The main loop's work, one step at a time, apart from the terminal: send
//! what the app queued, fold in Linear's answers, draw, record the view,
//! and carry out what an agent asks on the control channel.
//!
//! `main` drives a [`Runtime`] against the real terminal, or — headless —
//! against an in-memory one that only agents read.

use std::sync::Arc;
use std::time::{Duration, Instant};

use anyhow::Result;
use crossterm::event::Event;
use ratatui::buffer::Buffer;
use ratatui::{Terminal, backend::Backend};
use tokio::sync::{mpsc, oneshot};
use unicode_width::UnicodeWidthStr;

use crate::core::entity::Origin;
use crate::core::message::Message;
use crate::core::usecase::{Request, agent, favorite, issue, notes, project};
use crate::infra::disk::snapshot::{Recorder, timestamp_now};
use crate::infra::dispatch;
use crate::infra::herdr::AgentWatch;
use crate::infra::linear::client::LinearClient;
use crate::interface::control::{Asked, Command, Reply};
use crate::interface::control::{notation, screen};
use crate::interface::tui::app::App;
use crate::interface::tui::{event, palette, ui};

/// Spinner advance interval.
const TICK: Duration = Duration::from_millis(80);

/// How long an agent's command waits for Linear before it is answered with
/// the screen as it is, still loading.
const SETTLE_TIMEOUT: Duration = Duration::from_secs(15);

pub struct Runtime {
    pub app: App,
    cache: ui::Cache,
    client: Arc<LinearClient>,
    tx: mpsc::UnboundedSender<Message>,
    rx: mpsc::UnboundedReceiver<Message>,
    origin: Origin,
    recorder: Option<Recorder>,
    /// What the herdr plugin says its agents work on; only inside herdr.
    agents: Option<AgentWatch>,
    last_tick: Instant,
    /// Whether the next [`Runtime::draw`] has anything new to show.
    dirty: bool,
    /// Commands from agents, when the control channel is open.
    control: Option<mpsc::UnboundedReceiver<Asked>>,
    /// Commands carried out and waiting for Linear before they are answered.
    waiting: Vec<(oneshot::Sender<Reply>, Instant)>,
    /// The frame last drawn, as text, for agents.
    frame: Vec<String>,
    size: (u16, u16),
    /// What would act outside linear-tui — a browser, herdr — is held
    /// rather than done, when nobody is at the desktop (headless).
    hold_external: bool,
    held: Vec<String>,
}

impl Runtime {
    pub fn new(app: App, client: LinearClient, origin: Origin, recorder: Option<Recorder>) -> Self {
        let (tx, rx) = mpsc::unbounded_channel();
        // Only the herdr plugin writes the agents file.
        let agents = app.herdr.then(AgentWatch::new).flatten();
        Self {
            app,
            cache: ui::Cache::default(),
            client: Arc::new(client),
            tx,
            rx,
            origin,
            recorder,
            agents,
            last_tick: Instant::now(),
            dirty: true,
            control: None,
            waiting: Vec::new(),
            frame: Vec::new(),
            size: (0, 0),
            hold_external: false,
            held: Vec::new(),
        }
    }

    /// Start a new session in place: another workspace's `client`, and a
    /// fresh `app`, since nothing loaded from one workspace means anything in
    /// the next. Answers still on their way from the last session have
    /// nowhere to land. The view recorder, the control channel, and the
    /// commands waiting on it carry over.
    pub fn restart(&mut self, app: App, client: LinearClient) {
        let (tx, rx) = mpsc::unbounded_channel();
        self.app = app;
        self.client = Arc::new(client);
        self.tx = tx;
        self.rx = rx;
        self.cache = ui::Cache::default();
        self.dirty = true;
    }

    /// Take commands from agents.
    pub fn accept_control(&mut self, commands: mpsc::UnboundedReceiver<Asked>) {
        self.control = Some(commands);
    }

    /// Hold what would act outside linear-tui instead of doing it.
    pub fn hold_external(&mut self) {
        self.hold_external = true;
    }

    /// Say, in the screen agents read, what was not done at the desktop.
    pub fn note_held(&mut self, what: String) {
        self.held.push(what);
    }

    /// Spawn everything the UI has queued since the last pass, including a
    /// palette query that has rested until `now`. Each request runs on the
    /// tokio runtime, so the UI never blocks on the network.
    pub fn send_queued(&mut self, now: Instant) {
        if self.app.flush_palette_search(now) {
            self.dirty = true;
        }
        while let Some(req) = self.app.outbox.requests.pop_front() {
            self.app.outbox.inflight += 1;
            self.dirty = true;
            if self.hold_external
                && let Some((what, done)) = external(&req)
            {
                self.held.push(what);
                let _ = self.tx.send(Message::Mutated(done));
                continue;
            }
            let client = Arc::clone(&self.client);
            let tx = self.tx.clone();
            let per_page = self.app.items_per_page;
            tokio::spawn(async move {
                let msg = dispatch::execute_request(&client, req, per_page).await;
                let _ = tx.send(msg);
            });
        }
    }

    /// Draw a frame if anything changed since the last one.
    pub fn draw<B>(&mut self, terminal: &mut Terminal<B>) -> Result<()>
    where
        B: Backend,
        B::Error: Send + Sync + 'static,
    {
        if !self.dirty {
            return Ok(());
        }
        let completed = terminal.draw(|f| ui::draw(f, &mut self.app, &mut self.cache))?;
        if self.control.is_some() {
            self.frame = text_of(completed.buffer);
            self.size = (completed.area.width, completed.area.height);
        }
        self.dirty = false;
        Ok(())
    }

    /// Handle every answer that has arrived, without waiting for more.
    /// Returns whether there was one.
    pub fn receive(&mut self) -> bool {
        let mut moved = false;
        while let Ok(msg) = self.rx.try_recv() {
            self.app.outbox.inflight = self.app.outbox.inflight.saturating_sub(1);
            self.app.handle_message(msg);
            moved = true;
        }
        moved
    }

    /// Carry out the commands agents have sent. Returns whether any came.
    pub fn serve_control(&mut self) -> bool {
        let mut asked = Vec::new();
        if let Some(control) = &mut self.control {
            while let Ok(one) = control.try_recv() {
                asked.push(one);
            }
        }
        let moved = !asked.is_empty();
        for (command, answer) in asked {
            match self.carry_out(command) {
                Ok(()) => self.waiting.push((answer, Instant::now())),
                Err(reply) => {
                    let _ = answer.send(reply);
                }
            }
        }
        if moved {
            self.dirty = true;
        }
        moved
    }

    /// Do what a command asks. An error is answered at once; anything else
    /// is answered with the screen once Linear has caught up.
    fn carry_out(&mut self, command: Command) -> Result<(), Reply> {
        match command {
            Command::Screen => {}
            Command::Press { keys } => {
                let keys = notation::parse(&keys).map_err(|e| Reply::error(e.to_string()))?;
                for key in keys {
                    event::handle(&mut self.app, Event::Key(key));
                }
            }
            Command::Type { text } => {
                for c in text.chars() {
                    event::handle(&mut self.app, Event::Key(notation::char_key(c)));
                }
            }
            Command::Run { title } => {
                self.app.cancel_restore();
                palette::run_command(&mut self.app, &title).map_err(Reply::error)?;
            }
            Command::Open { issue } => {
                let key = crate::interface::cli::issue_key(&issue)
                    .map_err(|e| Reply::error(e.to_string()))?;
                self.app.cancel_restore();
                self.app.open_issue_by_identifier(&key);
            }
            Command::Quit => {
                self.app.quit();
                return Err(Reply::done());
            }
        }
        Ok(())
    }

    /// Whether everything asked of Linear has been answered and drawn.
    fn settled(&self) -> bool {
        self.app.outbox.requests.is_empty()
            && self.app.outbox.inflight == 0
            && self.app.view.palette.search_due.is_none()
            && !self.dirty
    }

    /// Answer the commands waiting on Linear, once it has answered — or once
    /// they have waited long enough, with the screen still loading.
    pub fn answer_waiting(&mut self, now: Instant) {
        if self.waiting.is_empty() {
            return;
        }
        let settled = self.settled();
        let (ready, still): (Vec<_>, Vec<_>) = std::mem::take(&mut self.waiting)
            .into_iter()
            .partition(|(_, since)| settled || now.duration_since(*since) >= SETTLE_TIMEOUT);
        self.waiting = still;
        if ready.is_empty() {
            return;
        }
        // The agent may read `linear-tui context` next: record the view it
        // is about to be told of, rather than after the usual rest.
        if let Some(recorder) = &mut self.recorder
            && let Some(snapshot) = self.app.snapshot(&self.origin, timestamp_now())
        {
            recorder.record(snapshot);
        }
        let report = screen::report(&self.app, self.frame.clone(), self.size, self.held.clone());
        for (answer, _) in ready {
            let _ = answer.send(Reply::screen(&report));
        }
    }

    /// End a pass: record the view once it has rested, follow herdr's
    /// agents, and advance the spinner. `moved` says whether input or an
    /// answer was handled in this pass.
    pub fn settle(&mut self, moved: bool, now: Instant) {
        // This is the only place a snapshot is written — never from rendering.
        if let Some(recorder) = &mut self.recorder {
            if moved {
                recorder.touch(now);
            }
            if recorder.is_due(now)
                && let Some(snapshot) = self.app.snapshot(&self.origin, timestamp_now())
            {
                recorder.record(snapshot);
            }
        }
        self.dirty |= moved;

        if let Some(watch) = &mut self.agents
            && let Some(list) = watch.poll(now)
        {
            self.app.set_agents(list);
            self.dirty = true;
        }

        if self.app.loading() && self.last_tick.elapsed() >= TICK {
            self.app.tick_spinner();
            self.last_tick = Instant::now();
            self.dirty = true;
        }
    }

    /// Draw the next frame even if nothing seems to have changed.
    pub fn touch(&mut self) {
        self.dirty = true;
    }

    /// The instance is quitting: write the view one last time.
    pub fn close(&mut self) {
        if let Some(recorder) = &mut self.recorder
            && let Some(snapshot) = self.app.snapshot(&self.origin, timestamp_now())
        {
            recorder.close(snapshot);
        }
    }
}

/// For a request that acts outside linear-tui rather than on Linear: what
/// it would do, and the status line it ends with.
fn external(request: &Request) -> Option<(String, &'static str)> {
    let browser = |url: &str| {
        (
            format!("would open {url} in a browser"),
            "Opened in browser",
        )
    };
    match request {
        Request::Issue(issue::Request::OpenInBrowser(url))
        | Request::Project(project::Request::OpenInBrowser(url))
        | Request::Favorite(favorite::Request::OpenInBrowser(url)) => Some(browser(url)),
        Request::Notes(notes::Request::Deliver(handoff)) => {
            Some(("would hand the notes to herdr".into(), handoff.done()))
        }
        Request::Agent(agent::Request::Focus { pane }) => Some((
            format!("would bring herdr pane {pane} to the front"),
            "Switched to the agent",
        )),
        _ => None,
    }
}

/// A frame as text, one line per row, trailing blanks trimmed. A wide
/// character is one `char`, though it fills two cells.
fn text_of(buffer: &Buffer) -> Vec<String> {
    let area = buffer.area;
    (area.y..area.y + area.height)
        .map(|y| {
            let mut line = String::new();
            let mut x = area.x;
            while x < area.x + area.width {
                let symbol = buffer[(x, y)].symbol();
                line.push_str(symbol);
                x += symbol.width().max(1) as u16;
            }
            line.trim_end().to_string()
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::backend::TestBackend;
    use ratatui::layout::Rect;

    #[test]
    fn a_frame_reads_as_text_with_wide_characters_whole() {
        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 2));
        buffer.set_string(0, 0, "天気 ok", ratatui::style::Style::default());
        buffer.set_string(0, 1, "row two", ratatui::style::Style::default());
        assert_eq!(text_of(&buffer), ["天気 ok", "row two"]);
    }

    #[test]
    fn a_browser_or_herdr_hand_off_is_external() {
        let open = Request::Issue(issue::Request::OpenInBrowser("https://x".into()));
        assert_eq!(
            external(&open),
            Some((
                "would open https://x in a browser".into(),
                "Opened in browser"
            ))
        );
        let status = Request::Issue(issue::Request::Detail {
            issue_id: "i".into(),
        });
        assert_eq!(external(&status), None);
    }

    /// A test terminal the runtime can draw on.
    fn terminal() -> Terminal<TestBackend> {
        Terminal::new(TestBackend::new(80, 20)).unwrap()
    }

    #[tokio::test]
    async fn an_agents_keys_are_answered_with_the_screen_they_lead_to() {
        let mut app = App::new(&crate::config::Config::default());
        app.outbox.requests.clear();
        app.store.teams =
            vec![serde_json::from_str(r#"{"id":"t","name":"Engineering","key":"ENG"}"#).unwrap()];
        let client = LinearClient::with_header("unused".into());
        let origin = Origin {
            workspace: "/repo".into(),
            cwd: "/repo".into(),
            pid: 1,
            herdr_pane: None,
        };
        let mut runtime = Runtime::new(app, client, origin, None);
        let (tx, rx) = mpsc::unbounded_channel();
        runtime.accept_control(rx);
        let mut terminal = terminal();

        let (answer, answered) = oneshot::channel();
        tx.send((Command::Press { keys: "?".into() }, answer))
            .unwrap();
        assert!(runtime.serve_control());
        runtime.draw(&mut terminal).unwrap();
        runtime.answer_waiting(Instant::now());
        let reply = answered.await.unwrap();
        let screen = reply.screen.unwrap();
        assert!(screen["overlay"].as_str().unwrap().starts_with("help"));
        assert!(
            screen["lines"]
                .as_array()
                .unwrap()
                .iter()
                .any(|l| l.as_str().unwrap().contains("Help"))
        );

        let (answer, answered) = oneshot::channel();
        tx.send((
            Command::Press {
                keys: "<Nope>".into(),
            },
            answer,
        ))
        .unwrap();
        runtime.serve_control();
        assert!(
            answered
                .await
                .unwrap()
                .error
                .unwrap()
                .contains("unknown key")
        );
    }
}