1use std::sync::mpsc;
43use std::thread;
44use std::time::Duration;
45
46use super::{Renderer, UiLine};
47
48enum RenderCmd {
50 Line(UiLine),
51 Flush,
52 FlushDeferred,
53 Resize(u16, u16),
56 PopApprovalPrompt,
58 ScrollBody(i32),
62 ScrollBodyToTop,
64 ScrollBodyToBottom,
65 BeginSelection(u16, u16),
69 UpdateSelection(u16, u16),
70 EndSelection,
71 CopySelection(mpsc::Sender<bool>),
76 Ack {
79 op: AckOp,
80 ack: mpsc::Sender<()>,
81 },
82}
83
84#[derive(Debug, Clone, Copy)]
85enum AckOp {
86 Reset,
87 ClearScreen,
88 SuspendForExternal,
89 ResumeFromExternal,
90 Shutdown,
91}
92
93pub struct TaskRenderer {
98 cmd_tx: mpsc::Sender<RenderCmd>,
99 worker: Option<thread::JoinHandle<()>>,
102}
103
104impl TaskRenderer {
105 pub fn new(inner: Box<dyn Renderer>) -> Self {
109 let (cmd_tx, cmd_rx) = mpsc::channel::<RenderCmd>();
110 let worker = thread::Builder::new()
111 .name("tuix-render".to_string())
112 .spawn(move || run_worker(inner, cmd_rx))
113 .expect("spawn render worker thread");
114 Self {
115 cmd_tx,
116 worker: Some(worker),
117 }
118 }
119
120 fn ack(&self, op: AckOp) {
133 let (ack_tx, ack_rx) = mpsc::channel();
134 if self
135 .cmd_tx
136 .send(RenderCmd::Ack { op, ack: ack_tx })
137 .is_err()
138 {
139 return;
141 }
142 let _ = ack_rx.recv_timeout(Duration::from_secs(10));
143 }
144}
145
146impl Renderer for TaskRenderer {
147 fn render(&mut self, line: UiLine) {
148 let _ = self.cmd_tx.send(RenderCmd::Line(line));
149 }
150
151 fn flush(&mut self) {
152 let _ = self.cmd_tx.send(RenderCmd::Flush);
153 }
154
155 fn shutdown(&mut self) {
156 self.ack(AckOp::Shutdown);
157 }
158
159 fn reset(&mut self) {
160 self.ack(AckOp::Reset);
161 }
162
163 fn clear_screen(&mut self) {
164 self.ack(AckOp::ClearScreen);
165 }
166
167 fn suspend_for_external(&mut self) {
168 self.ack(AckOp::SuspendForExternal);
169 }
170
171 fn resume_from_external(&mut self) {
172 self.ack(AckOp::ResumeFromExternal);
173 }
174
175 fn flush_deferred(&mut self) {
176 let _ = self.cmd_tx.send(RenderCmd::FlushDeferred);
177 }
178
179 fn on_resize(&mut self, cols: u16, rows: u16) {
180 let _ = self.cmd_tx.send(RenderCmd::Resize(cols, rows));
181 }
182
183 fn pop_approval_prompt(&mut self) {
184 let _ = self.cmd_tx.send(RenderCmd::PopApprovalPrompt);
185 }
186
187 fn scroll_body(&mut self, delta: i32) {
188 let _ = self.cmd_tx.send(RenderCmd::ScrollBody(delta));
189 }
190
191 fn scroll_body_to_top(&mut self) {
192 let _ = self.cmd_tx.send(RenderCmd::ScrollBodyToTop);
193 }
194
195 fn scroll_body_to_bottom(&mut self) {
196 let _ = self.cmd_tx.send(RenderCmd::ScrollBodyToBottom);
197 }
198
199 fn begin_selection(&mut self, col: u16, row: u16) {
200 let _ = self.cmd_tx.send(RenderCmd::BeginSelection(col, row));
201 }
202
203 fn update_selection(&mut self, col: u16, row: u16) {
204 let _ = self.cmd_tx.send(RenderCmd::UpdateSelection(col, row));
205 }
206
207 fn end_selection(&mut self) {
208 let _ = self.cmd_tx.send(RenderCmd::EndSelection);
209 }
210
211 fn copy_selection(&mut self) -> bool {
212 let (ack_tx, ack_rx) = mpsc::channel();
213 if self.cmd_tx.send(RenderCmd::CopySelection(ack_tx)).is_err() {
214 return false;
215 }
216 ack_rx.recv_timeout(Duration::from_secs(5)).unwrap_or(false)
217 }
218}
219
220impl Drop for TaskRenderer {
221 fn drop(&mut self) {
222 self.ack(AckOp::Shutdown);
226 if let Some(handle) = self.worker.take() {
227 let _ = handle.join();
228 }
229 }
230}
231
232fn run_worker(mut inner: Box<dyn Renderer>, cmd_rx: mpsc::Receiver<RenderCmd>) {
233 use std::time::Instant;
234 while let Ok(cmd) = cmd_rx.recv() {
235 match cmd {
241 RenderCmd::Line(line) => {
242 let tag = ui_line_tag(&line);
243 let t0 = Instant::now();
244 inner.render(line);
245 crate::tuix_trace!("REN", "Line {} render={}µs", tag, t0.elapsed().as_micros());
246 }
247 RenderCmd::Flush => {
248 let t0 = Instant::now();
249 inner.flush();
250 crate::tuix_trace!("REN", "Flush flush={}µs", t0.elapsed().as_micros());
251 }
252 RenderCmd::FlushDeferred => {
253 let t0 = Instant::now();
257 inner.flush_deferred();
258 let d = t0.elapsed();
259 if d.as_micros() > 100 {
260 crate::tuix_trace!("REN", "FlushDeferred deferred={}µs", d.as_micros());
261 }
262 }
263 RenderCmd::Resize(cols, rows) => {
264 let t0 = Instant::now();
265 inner.on_resize(cols, rows);
266 crate::tuix_trace!(
267 "REN",
268 "Resize {}x{} dur={}µs",
269 cols,
270 rows,
271 t0.elapsed().as_micros()
272 );
273 }
274 RenderCmd::PopApprovalPrompt => {
275 inner.pop_approval_prompt();
276 }
277 RenderCmd::ScrollBody(delta) => {
278 inner.scroll_body(delta);
279 }
280 RenderCmd::ScrollBodyToTop => {
281 inner.scroll_body_to_top();
282 }
283 RenderCmd::ScrollBodyToBottom => {
284 inner.scroll_body_to_bottom();
285 }
286 RenderCmd::BeginSelection(col, row) => {
287 inner.begin_selection(col, row);
288 }
289 RenderCmd::UpdateSelection(col, row) => {
290 inner.update_selection(col, row);
291 }
292 RenderCmd::EndSelection => {
293 inner.end_selection();
294 }
295 RenderCmd::CopySelection(ack) => {
296 let result = inner.copy_selection();
297 let _ = ack.send(result);
298 }
299 RenderCmd::Ack { op, ack } => {
300 let t0 = Instant::now();
301 match op {
302 AckOp::Reset => inner.reset(),
303 AckOp::ClearScreen => inner.clear_screen(),
304 AckOp::SuspendForExternal => inner.suspend_for_external(),
305 AckOp::ResumeFromExternal => inner.resume_from_external(),
306 AckOp::Shutdown => {
307 inner.shutdown();
308 crate::tuix_trace!(
309 "REN",
310 "Ack Shutdown dur={}µs",
311 t0.elapsed().as_micros()
312 );
313 let _ = ack.send(());
314 return;
319 }
320 }
321 crate::tuix_trace!("REN", "Ack {:?} dur={}µs", op, t0.elapsed().as_micros());
322 let _ = ack.send(());
323 }
324 }
325 }
326 inner.shutdown();
329}
330
331fn ui_line_tag(l: &UiLine) -> &'static str {
334 match l {
335 UiLine::Welcome { .. } => "Welcome",
336 UiLine::User(_) => "User",
337 UiLine::AssistantText(_) => "AssistantText",
338 UiLine::ReasoningText(_) => "ReasoningText",
339 UiLine::AssistantLineBreak => "AssistantLineBreak",
340 UiLine::ToolCall { .. } => "ToolCall",
341 UiLine::ToolCallInFlight { .. } => "ToolCallInFlight",
342 UiLine::ToolCallCommit { .. } => "ToolCallCommit",
343 UiLine::ToolGroupRender { .. } => "ToolGroupRender",
344 UiLine::ToolGroupChildUpdate { .. } => "ToolGroupChildUpdate",
345 UiLine::ToolGroupSummary { .. } => "ToolGroupSummary",
346 UiLine::ToolResult { .. } => "ToolResult",
347 UiLine::DiffLine { .. } => "DiffLine",
348 UiLine::DiffBlock(_) => "DiffBlock",
349 UiLine::ApprovalPrompt { .. } => "ApprovalPrompt",
350 UiLine::Error(_) => "Error",
351 UiLine::Warning(_) => "Warning",
352 UiLine::TurnCancelled => "TurnCancelled",
353 UiLine::TurnComplete => "TurnComplete",
354 UiLine::Spinner { .. } => "Spinner",
355 UiLine::StreamingBox { .. } => "StreamingBox",
356 UiLine::ClearTransient => "ClearTransient",
357 UiLine::InputPrompt { .. } => "InputPrompt",
358 UiLine::InputCommit => "InputCommit",
359 UiLine::CommandOutput(_) => "CommandOutput",
360 UiLine::ImageAttachment(_) => "ImageAttachment",
361 UiLine::VisionPreprocessSuccess { .. } => "VisionPreprocessSuccess",
362 UiLine::TurnSeparator { .. } => "TurnSeparator",
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use crate::render::Renderer;
370 use std::sync::{Arc, Mutex};
371
372 #[derive(Default)]
375 struct Counts {
376 renders: usize,
377 flushes: usize,
378 shutdowns: usize,
379 resets: usize,
380 clear_screens: usize,
381 suspends: usize,
382 resumes: usize,
383 deferred: usize,
384 }
385
386 struct TestRenderer {
387 counts: Arc<Mutex<Counts>>,
388 }
389
390 impl Renderer for TestRenderer {
391 fn render(&mut self, _line: UiLine) {
392 self.counts.lock().unwrap().renders += 1;
393 }
394 fn flush(&mut self) {
395 self.counts.lock().unwrap().flushes += 1;
396 }
397 fn shutdown(&mut self) {
398 self.counts.lock().unwrap().shutdowns += 1;
399 }
400 fn reset(&mut self) {
401 self.counts.lock().unwrap().resets += 1;
402 }
403 fn clear_screen(&mut self) {
404 self.counts.lock().unwrap().clear_screens += 1;
405 }
406 fn suspend_for_external(&mut self) {
407 self.counts.lock().unwrap().suspends += 1;
408 }
409 fn resume_from_external(&mut self) {
410 self.counts.lock().unwrap().resumes += 1;
411 }
412 fn flush_deferred(&mut self) {
413 self.counts.lock().unwrap().deferred += 1;
414 }
415 }
416
417 fn setup() -> (TaskRenderer, Arc<Mutex<Counts>>) {
418 let counts = Arc::new(Mutex::new(Counts::default()));
419 let inner = Box::new(TestRenderer {
420 counts: counts.clone(),
421 });
422 (TaskRenderer::new(inner), counts)
423 }
424
425 #[test]
426 fn render_and_flush_forward_to_inner() {
427 let (mut r, counts) = setup();
428 r.render(UiLine::User("hi".into()));
429 r.render(UiLine::User("there".into()));
430 r.flush();
431 r.reset();
435 let c = counts.lock().unwrap();
436 assert_eq!(c.renders, 2);
437 assert_eq!(c.flushes, 1);
438 assert_eq!(c.resets, 1);
439 }
440
441 #[test]
442 fn lifecycle_ack_blocks_until_worker_done() {
443 let (mut r, counts) = setup();
444 r.clear_screen();
447 assert_eq!(counts.lock().unwrap().clear_screens, 1);
448 r.suspend_for_external();
449 assert_eq!(counts.lock().unwrap().suspends, 1);
450 r.resume_from_external();
451 assert_eq!(counts.lock().unwrap().resumes, 1);
452 }
453
454 #[test]
455 fn shutdown_drops_worker_and_later_sends_are_noops() {
456 let (mut r, counts) = setup();
457 r.render(UiLine::User("before".into()));
458 r.shutdown();
459 assert_eq!(counts.lock().unwrap().shutdowns, 1);
460 r.render(UiLine::User("after".into()));
463 r.flush();
464 r.shutdown();
466 }
467
468 #[test]
469 fn drop_triggers_shutdown_when_not_called_explicitly() {
470 let counts = {
471 let counts = Arc::new(Mutex::new(Counts::default()));
472 let inner = Box::new(TestRenderer {
473 counts: counts.clone(),
474 });
475 let mut r = TaskRenderer::new(inner);
476 r.render(UiLine::User("one".into()));
477 counts
478 };
480 let c = counts.lock().unwrap();
483 assert_eq!(c.renders, 1);
484 assert_eq!(c.shutdowns, 1);
485 }
486
487 #[test]
488 fn flush_deferred_fire_and_forget() {
489 let (mut r, counts) = setup();
490 r.flush_deferred();
491 r.reset();
494 assert_eq!(counts.lock().unwrap().deferred, 1);
495 }
496}