返回 CodeWhale
workbar.rs
1 //! Codewhale's work dock: Tasks, Fleet, Jobs, Files, Notes, Context, Git, Cost.
2 //!
3 //! Extracted from `CodeWhale/crates/tui/src/tui/work_surface/{model,input,
4 //! render/{mod,layout,rows}}.rs` at `a79ce5c4d5ed1a5f7032185710c27343a900351c`.
5 //! The host supplies rows and dispatches actions; the dock retains the native
6 //! tab fit, row columns, placement, goal header, and overflow behavior.
7
8 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
9 use ratatui::{
10 buffer::Buffer,
11 layout::Rect,
12 style::{Modifier, Style},
13 text::{Line, Span},
14 };
15
16 use crate::{Paint, Role, Theme, TuiInk, glyphs, text};
17
18 use super::workbench::row;
19
20 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
21 pub enum WorkbarPanel {
22 #[default]
23 Tasks,
24 Fleet,
25 Jobs,
26 Files,
27 Notes,
28 Context,
29 Git,
30 Cost,
31 }
32
33 impl WorkbarPanel {
34 pub const ORDER: [Self; 8] = [
35 Self::Tasks,
36 Self::Fleet,
37 Self::Jobs,
38 Self::Files,
39 Self::Notes,
40 Self::Context,
41 Self::Git,
42 Self::Cost,
43 ];
44
45 #[must_use]
46 pub const fn label(self) -> &'static str {
47 match self {
48 Self::Tasks => "Tasks",
49 Self::Fleet => "Fleet",
50 Self::Jobs => "Jobs",
51 Self::Files => "Files",
52 Self::Notes => "Notes",
53 Self::Context => "Context",
54 Self::Git => "Git",
55 Self::Cost => "Cost",
56 }
57 }
58
59 #[must_use]
60 pub const fn empty_label(self) -> &'static str {
61 match self {
62 Self::Tasks => "no to-dos yet",
63 Self::Fleet => "no agents have run this session",
64 Self::Jobs => "nothing running in the background",
65 Self::Files => "no files touched this session",
66 Self::Notes => "/note add <text> to keep a note",
67 Self::Context => "context budget unknown",
68 Self::Git => "reading git status…",
69 Self::Cost => "no priced turns yet",
70 }
71 }
72
73 #[must_use]
74 pub fn next(self) -> Self {
75 Self::ORDER[(self.index() + 1) % Self::ORDER.len()]
76 }
77
78 #[must_use]
79 pub fn prev(self) -> Self {
80 Self::ORDER[(self.index() + Self::ORDER.len() - 1) % Self::ORDER.len()]
81 }
82
83 fn index(self) -> usize {
84 Self::ORDER
85 .iter()
86 .position(|panel| *panel == self)
87 .unwrap_or(0)
88 }
89 }
90
91 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92 pub enum WorkbarPlacement {
93 #[default]
94 Bottom,
95 Top,
96 Left,
97 Right,
98 Off,
99 }
100
101 impl WorkbarPlacement {
102 #[must_use]
103 pub const fn is_strip(self) -> bool {
104 matches!(self, Self::Top | Self::Bottom)
105 }
106 }
107
108 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109 pub enum WorkbarTone {
110 Heading,
111 Live,
112 Attention,
113 Failure,
114 Success,
115 #[default]
116 Muted,
117 }
118
119 impl WorkbarTone {
120 fn style(self, theme: &Theme) -> Style {
121 match self {
122 Self::Heading | Self::Muted => theme.fg(Role::Muted),
123 Self::Live => theme.tui_ink(TuiInk::Working),
124 Self::Success => theme.tui_ink(TuiInk::Success),
125 Self::Attention => theme.tui_ink(TuiInk::Warning),
126 Self::Failure => theme.fg(Role::Danger),
127 }
128 }
129 }
130
131 #[derive(Clone, Debug, PartialEq, Eq)]
132 pub struct WorkbarTab {
133 pub panel: WorkbarPanel,
134 /// `None` for fact views, matching the native unbadged Context/Git/Cost.
135 pub count: Option<usize>,
136 }
137
138 impl WorkbarTab {
139 #[must_use]
140 pub const fn new(panel: WorkbarPanel) -> Self {
141 Self { panel, count: None }
142 }
143
144 #[must_use]
145 pub const fn count(mut self, count: usize) -> Self {
146 self.count = if matches!(
147 self.panel,
148 WorkbarPanel::Context | WorkbarPanel::Git | WorkbarPanel::Cost
149 ) {
150 None
151 } else {
152 Some(count)
153 };
154 self
155 }
156 }
157
158 /// Fleet columns drop tokens, then the receipt, then identity as room shrinks.
159 #[derive(Clone, Debug, Default, PartialEq, Eq)]
160 pub struct WorkbarAgent {
161 pub role: String,
162 pub status: String,
163 pub objective: String,
164 pub model: Option<String>,
165 pub elapsed_seconds: Option<u64>,
166 pub tokens: Option<u64>,
167 pub remaining: Option<u32>,
168 }
169
170 impl WorkbarAgent {
171 #[must_use]
172 pub fn new(
173 role: impl Into<String>,
174 status: impl Into<String>,
175 objective: impl Into<String>,
176 ) -> Self {
177 Self {
178 role: role.into(),
179 status: status.into(),
180 objective: objective.into(),
181 ..Self::default()
182 }
183 }
184 #[must_use]
185 pub fn model(mut self, value: impl Into<String>) -> Self {
186 self.model = Some(value.into());
187 self
188 }
189 #[must_use]
190 pub const fn elapsed_seconds(mut self, value: u64) -> Self {
191 self.elapsed_seconds = Some(value);
192 self
193 }
194 #[must_use]
195 pub const fn tokens(mut self, value: u64) -> Self {
196 self.tokens = Some(value);
197 self
198 }
199 #[must_use]
200 pub const fn remaining(mut self, value: u32) -> Self {
201 self.remaining = Some(value);
202 self
203 }
204 }
205
206 #[derive(Clone, Debug, PartialEq, Eq)]
207 pub struct WorkbarRow {
208 pub id: String,
209 pub mark: String,
210 pub label: String,
211 pub detail: String,
212 pub tone: WorkbarTone,
213 pub selectable: bool,
214 pub agent: Option<WorkbarAgent>,
215 }
216
217 impl WorkbarRow {
218 #[must_use]
219 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
220 Self {
221 id: id.into(),
222 mark: "○".into(),
223 label: label.into(),
224 detail: String::new(),
225 tone: WorkbarTone::Muted,
226 selectable: true,
227 agent: None,
228 }
229 }
230 #[must_use]
231 pub fn mark(mut self, value: impl Into<String>) -> Self {
232 self.mark = value.into();
233 self
234 }
235 #[must_use]
236 pub fn detail(mut self, value: impl Into<String>) -> Self {
237 self.detail = value.into();
238 self
239 }
240 #[must_use]
241 pub const fn tone(mut self, value: WorkbarTone) -> Self {
242 self.tone = value;
243 self
244 }
245 #[must_use]
246 pub const fn selectable(mut self, value: bool) -> Self {
247 self.selectable = value;
248 self
249 }
250 #[must_use]
251 pub fn agent(mut self, value: WorkbarAgent) -> Self {
252 self.agent = Some(value);
253 self
254 }
255 }
256
257 #[derive(Clone, Debug, PartialEq, Eq)]
258 pub enum WorkbarTarget {
259 Panel(WorkbarPanel),
260 Row(String),
261 Close,
262 Divider,
263 }
264
265 #[derive(Clone, Debug, PartialEq, Eq)]
266 pub struct WorkbarHitbox {
267 pub target: WorkbarTarget,
268 pub area: Rect,
269 }
270
271 /// Split the host area once, then paint the dock with this effective placement.
272 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
273 pub struct WorkbarRegions {
274 pub content: Rect,
275 pub dock: Option<Rect>,
276 pub placement: WorkbarPlacement,
277 }
278
279 /// Geometry used by painting, pointer targets, and keyboard scrolling.
280 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
281 pub struct WorkbarLayout {
282 pub body: Rect,
283 pub content: Rect,
284 pub visible_rows: usize,
285 pub offset: usize,
286 pub overflow: bool,
287 pub more_row: bool,
288 pub goal_height: u16,
289 pub progress_height: u16,
290 }
291
292 impl WorkbarLayout {
293 #[must_use]
294 pub const fn progress_shares_goal_row(width: u16, has_goal: bool) -> bool {
295 has_goal && width >= 72
296 }
297
298 /// Fit headers, visible rows, overflow reserve and offset inside the caller's
299 /// existing body viewport. Placement/focus/action policy is not inferred.
300 #[must_use]
301 pub fn for_body(
302 body: Rect,
303 total_rows: usize,
304 offset: usize,
305 has_goal: bool,
306 has_progress: bool,
307 ) -> Self {
308 let goal_height = u16::from(has_goal && body.height >= 2);
309 let fold = Self::progress_shares_goal_row(body.width, goal_height > 0);
310 let progress_height =
311 u16::from(has_progress && !fold && body.height.saturating_sub(goal_height) >= 2);
312 let header = goal_height.saturating_add(progress_height);
313 let list_height = body.height.saturating_sub(header);
314 let overflow = total_rows > usize::from(list_height);
315 let more_row = overflow && list_height >= 2;
316 let visible_rows = usize::from(list_height).saturating_sub(usize::from(more_row));
317 let offset = offset.min(total_rows.saturating_sub(visible_rows.max(1)));
318 let inset = u16::from(body.width >= 16);
319 let content = Rect {
320 x: body.x.saturating_add(inset),
321 y: body.y.saturating_add(header),
322 width: body
323 .width
324 .saturating_sub(inset.saturating_mul(2))
325 .saturating_sub(u16::from(overflow)),
326 height: list_height,
327 };
328 Self {
329 body,
330 content,
331 visible_rows,
332 offset,
333 overflow,
334 more_row,
335 goal_height,
336 progress_height,
337 }
338 }
339 }
340
341 /// The fitted body rail, over caller-supplied symbols and live styles.
342 /// Both the native Engine and Workbar use this paint path. No scroll state is
343 /// retained; the current admitted offset/row counts enter on each draw.
344 #[derive(Clone, Copy, Debug)]
345 pub struct WorkbarScrollbar<'a> {
346 pub offset: usize,
347 pub visible: usize,
348 pub total: usize,
349 pub thumb: &'a str,
350 pub track: &'a str,
351 pub thumb_style: Style,
352 pub track_style: Style,
353 }
354
355 impl WorkbarScrollbar<'_> {
356 pub fn paint(&self, area: Rect, buf: &mut Buffer) {
357 if area.is_empty() || self.total == 0 {
358 return;
359 }
360 let height = usize::from(area.height);
361 let thumb_height = height.saturating_mul(self.visible) / self.total;
362 let thumb_height = thumb_height.max(1).min(height);
363 let start = self
364 .offset
365 .saturating_mul(height.saturating_sub(thumb_height))
366 / self.total.saturating_sub(self.visible).max(1);
367 let x = area.right().saturating_sub(1);
368 for row in 0..height {
369 let active = row >= start && row < start.saturating_add(thumb_height);
370 if let Some(cell) = buf.cell_mut((x, area.y.saturating_add(row as u16))) {
371 cell.set_symbol(if active { self.thumb } else { self.track })
372 .set_style(if active {
373 self.thumb_style
374 } else {
375 self.track_style
376 });
377 }
378 }
379 }
380 }
381
382 /// The actual Codewhale dock, with application projections replaced by rows.
383 #[derive(Clone, Debug)]
384 pub struct Workbar {
385 pub panel: WorkbarPanel,
386 pub rows: Vec<WorkbarRow>,
387 pub tabs: Vec<WorkbarTab>,
388 pub placement: WorkbarPlacement,
389 pub goal: Option<String>,
390 pub goal_paused: bool,
391 pub progress: Option<String>,
392 pub selected: Option<String>,
393 pub hovered: Option<String>,
394 pub opened: Option<String>,
395 pub focused: bool,
396 pub offset: usize,
397 pub max_height: u16,
398 pub side_width: u16,
399 pub explicit: bool,
400 }
401
402 impl Workbar {
403 #[must_use]
404 pub fn new(panel: WorkbarPanel, rows: Vec<WorkbarRow>) -> Self {
405 let tabs = WorkbarPanel::ORDER
406 .into_iter()
407 .filter(|value| {
408 *value == panel
409 || matches!(
410 value,
411 WorkbarPanel::Context | WorkbarPanel::Git | WorkbarPanel::Cost
412 )
413 })
414 .map(WorkbarTab::new)
415 .collect();
416 Self {
417 panel,
418 rows,
419 tabs,
420 placement: WorkbarPlacement::Bottom,
421 goal: None,
422 goal_paused: false,
423 progress: None,
424 selected: None,
425 hovered: None,
426 opened: None,
427 focused: false,
428 offset: 0,
429 max_height: 5,
430 side_width: 30,
431 explicit: true,
432 }
433 }
434 #[must_use]
435 pub fn tabs(mut self, value: Vec<WorkbarTab>) -> Self {
436 self.tabs = value;
437 self
438 }
439 #[must_use]
440 pub const fn placement(mut self, value: WorkbarPlacement) -> Self {
441 self.placement = value;
442 self
443 }
444 #[must_use]
445 pub fn goal(mut self, value: impl Into<String>) -> Self {
446 self.goal = Some(value.into());
447 self
448 }
449 #[must_use]
450 pub const fn paused_goal(mut self, paused: bool) -> Self {
451 self.goal_paused = paused;
452 self
453 }
454 #[must_use]
455 pub fn progress(mut self, value: impl Into<String>) -> Self {
456 self.progress = Some(value.into());
457 self
458 }
459 #[must_use]
460 pub fn selected(mut self, value: impl Into<String>) -> Self {
461 self.selected = Some(value.into());
462 self
463 }
464 #[must_use]
465 pub fn hovered(mut self, value: impl Into<String>) -> Self {
466 self.hovered = Some(value.into());
467 self
468 }
469 #[must_use]
470 pub fn opened(mut self, value: impl Into<String>) -> Self {
471 self.opened = Some(value.into());
472 self
473 }
474 #[must_use]
475 pub const fn focused(mut self, value: bool) -> Self {
476 self.focused = value;
477 self
478 }
479 #[must_use]
480 pub const fn offset(mut self, value: usize) -> Self {
481 self.offset = value;
482 self
483 }
484 #[must_use]
485 pub fn max_height(mut self, value: u16) -> Self {
486 self.max_height = value.clamp(3, 16);
487 self
488 }
489 #[must_use]
490 pub fn side_width(mut self, value: u16) -> Self {
491 self.side_width = value.clamp(26, 80);
492 self
493 }
494 /// Automatic docks disappear when there are neither rows nor a live goal.
495 #[must_use]
496 pub const fn explicit(mut self, value: bool) -> Self {
497 self.explicit = value;
498 self
499 }
500
501 fn goal_text(&self) -> Option<String> {
502 let goal = safe(self.goal.as_deref()?);
503 if goal.trim().is_empty() {
504 return None;
505 }
506 Some(format!(
507 "Goal{}: {}",
508 if self.goal_paused { " (paused)" } else { "" },
509 goal.trim()
510 ))
511 }
512
513 fn requested_height(&self, width: u16) -> u16 {
514 if width == 0
515 || self.placement == WorkbarPlacement::Off
516 || (self.rows.is_empty() && !self.explicit && self.goal_text().is_none())
517 {
518 return 0;
519 }
520 if !self.placement.is_strip() {
521 return 0;
522 }
523 let goal = u16::from(self.goal_text().is_some());
524 let progress = u16::from(
525 self.progress.is_some() && !WorkbarLayout::progress_shares_goal_row(width, goal > 0),
526 );
527 (u16::try_from(self.rows.len().max(usize::from(self.explicit)))
528 .unwrap_or(u16::MAX)
529 .saturating_add(goal)
530 .saturating_add(progress)
531 .saturating_add(2))
532 .clamp(5.min(self.max_height), self.max_height)
533 }
534
535 /// Native auto-fit ceiling: half the terminal, the configured height, and
536 /// the rows the conversation can spare. Empty automatic docks collapse.
537 #[must_use]
538 pub fn height_for(&self, width: u16, terminal_height: u16, rail_budget: u16) -> u16 {
539 let cap = self
540 .max_height
541 .min((terminal_height / 2).clamp(5, 16))
542 .min(rail_budget);
543 if cap < 5 && (!self.explicit || cap < 3) {
544 return 0;
545 }
546 self.requested_height(width).min(cap)
547 }
548
549 #[must_use]
550 pub fn regions(&self, host: Rect) -> WorkbarRegions {
551 let host = representable(host);
552 let empty = self.rows.is_empty() && !self.explicit && self.goal_text().is_none();
553 if host.is_empty() || self.placement == WorkbarPlacement::Off || empty {
554 return WorkbarRegions {
555 content: host,
556 dock: None,
557 placement: self.placement,
558 };
559 }
560 let mut placement = self.placement;
561 if matches!(placement, WorkbarPlacement::Left | WorkbarPlacement::Right) && host.width < 72
562 {
563 placement = WorkbarPlacement::Top;
564 }
565 if placement.is_strip() {
566 let height = self
567 .clone()
568 .placement(placement)
569 .requested_height(host.width)
570 .min(host.height / 2);
571 if height < 3 {
572 return WorkbarRegions {
573 content: host,
574 dock: None,
575 placement,
576 };
577 }
578 let (content, dock) = if placement == WorkbarPlacement::Bottom {
579 (
580 Rect {
581 height: host.height - height,
582 ..host
583 },
584 Rect {
585 y: host.bottom() - height,
586 height,
587 ..host
588 },
589 )
590 } else {
591 (
592 Rect {
593 y: host.y.saturating_add(height),
594 height: host.height - height,
595 ..host
596 },
597 Rect { height, ..host },
598 )
599 };
600 return WorkbarRegions {
601 content,
602 dock: Some(dock),
603 placement,
604 };
605 }
606 let width = self.side_width.min(host.width.saturating_sub(40));
607 if width < 26 {
608 return self.clone().placement(WorkbarPlacement::Top).regions(host);
609 }
610 let (content, dock) = if placement == WorkbarPlacement::Left {
611 (
612 Rect {
613 x: host.x.saturating_add(width),
614 width: host.width - width,
615 ..host
616 },
617 Rect { width, ..host },
618 )
619 } else {
620 (
621 Rect {
622 width: host.width - width,
623 ..host
624 },
625 Rect {
626 x: host.right() - width,
627 width,
628 ..host
629 },
630 )
631 };
632 WorkbarRegions {
633 content,
634 dock: Some(dock),
635 placement,
636 }
637 }
638
639 #[must_use]
640 pub fn layout(&self, area: Rect) -> WorkbarLayout {
641 let area = representable(area);
642 let body = match self.placement {
643 WorkbarPlacement::Top => Rect {
644 y: area.y.saturating_add(1).min(area.bottom()),
645 height: area.height.saturating_sub(2),
646 ..area
647 },
648 WorkbarPlacement::Bottom => Rect {
649 y: area.y.saturating_add(2).min(area.bottom()),
650 height: area.height.saturating_sub(2),
651 ..area
652 },
653 WorkbarPlacement::Left => Rect {
654 width: area.width.saturating_sub(1),
655 ..area
656 },
657 WorkbarPlacement::Right => Rect {
658 x: area.x.saturating_add(1).min(area.right()),
659 width: area.width.saturating_sub(1),
660 ..area
661 },
662 WorkbarPlacement::Off => Rect { height: 0, ..area },
663 };
664 WorkbarLayout::for_body(
665 body,
666 self.rows.len(),
667 self.offset,
668 self.placement.is_strip() && self.goal_text().is_some(),
669 self.placement.is_strip() && self.progress.is_some(),
670 )
671 }
672
673 fn dock_tab_row(&self, area: Rect, theme: Option<&Theme>) -> super::DockTabRow<'_> {
674 let styles = theme.map_or_else(super::DockTabStyles::default, |theme| {
675 super::DockTabStyles {
676 idle: theme.fg(Role::Muted).patch(theme.bg(Role::Sidebar)),
677 active: theme
678 .fg(Role::Foreground)
679 .patch(theme.bg(Role::Selected))
680 .add_modifier(Modifier::BOLD),
681 close: theme.fg(Role::Hint),
682 ..Default::default()
683 }
684 });
685 let close = if self.focused && area.width >= 60 {
686 " Esc × "
687 } else {
688 " × "
689 };
690 super::DockTabRow {
691 tabs: &self.tabs,
692 active: self.panel,
693 bottom: self.placement == WorkbarPlacement::Bottom,
694 close: theme.map_or_else(|| close.into(), |theme| decorative(close, theme).into()),
695 hovered: None,
696 pressed: None,
697 styles,
698 }
699 }
700
701 #[must_use]
702 /// Intersect `area` with the render buffer before querying a clipped view.
703 pub fn hitboxes(&self, area: Rect) -> Vec<WorkbarHitbox> {
704 let area = representable(area);
705 if area.is_empty() || self.placement == WorkbarPlacement::Off {
706 return Vec::new();
707 }
708 let layout = self.layout(area);
709 let mut hitboxes = Vec::new();
710 if self.placement.is_strip() && area.height >= 2 {
711 hitboxes.extend(
712 self.dock_tab_row(area, None)
713 .plan(area)
714 .hitboxes()
715 .into_iter()
716 .map(|(target, area)| WorkbarHitbox {
717 target: match target {
718 super::DockTabTarget::Panel(panel) => WorkbarTarget::Panel(panel),
719 super::DockTabTarget::Close => WorkbarTarget::Close,
720 },
721 area,
722 }),
723 );
724 }
725 if layout.content.width > 0 {
726 hitboxes.extend(
727 self.rows
728 .iter()
729 .skip(layout.offset)
730 .take(layout.visible_rows)
731 .enumerate()
732 .filter(|(_, row)| row.selectable)
733 .map(|(index, value)| WorkbarHitbox {
734 target: WorkbarTarget::Row(value.id.clone()),
735 area: Rect::new(
736 layout.content.x,
737 layout.content.y.saturating_add(index as u16),
738 layout.content.width,
739 1,
740 ),
741 }),
742 );
743 }
744 hitboxes.push(WorkbarHitbox {
745 target: WorkbarTarget::Divider,
746 area: self.divider(area),
747 });
748 hitboxes
749 }
750
751 #[must_use]
752 pub fn target_at(&self, area: Rect, column: u16, row: u16) -> Option<WorkbarTarget> {
753 self.hitboxes(area)
754 .into_iter()
755 .find(|hitbox| hitbox.area.contains((column, row).into()))
756 .map(|hitbox| hitbox.target)
757 }
758
759 fn divider(&self, area: Rect) -> Rect {
760 match self.placement {
761 WorkbarPlacement::Top => Rect {
762 y: area.bottom().saturating_sub(1),
763 height: 1,
764 ..area
765 },
766 WorkbarPlacement::Bottom => Rect { height: 1, ..area },
767 WorkbarPlacement::Left => Rect {
768 x: area.right().saturating_sub(1),
769 width: 1,
770 ..area
771 },
772 WorkbarPlacement::Right => Rect { width: 1, ..area },
773 WorkbarPlacement::Off => Rect { height: 0, ..area },
774 }
775 }
776
777 fn row_style(&self, value: &WorkbarRow, theme: &Theme) -> Style {
778 let mut style = value.tone.style(theme).patch(theme.bg(Role::Sidebar));
779 if value.tone == WorkbarTone::Heading {
780 style = style.add_modifier(Modifier::BOLD);
781 }
782 if !value.selectable {
783 return style;
784 }
785 if self.opened.as_deref() == Some(&value.id) {
786 style = style
787 .patch(theme.fg(Role::Primary))
788 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
789 }
790 if self.focused && self.selected.as_deref() == Some(&value.id) {
791 style = style
792 .patch(theme.bg(Role::Selected))
793 .add_modifier(Modifier::BOLD);
794 } else if self.hovered.as_deref() == Some(&value.id) {
795 style = style.patch(theme.bg(Role::Hover));
796 }
797 style
798 }
799 }
800
801 impl Paint for Workbar {
802 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
803 let area = area.intersection(buf.area);
804 if area.is_empty()
805 || self.placement == WorkbarPlacement::Off
806 || (self.rows.is_empty() && !self.explicit && self.goal_text().is_none())
807 {
808 return;
809 }
810 let layout = self.layout(area);
811 for y in area.y..area.bottom() {
812 row(
813 Rect::new(area.x, y, area.width, 1),
814 buf,
815 &Line::styled(
816 " ".repeat(usize::from(area.width)),
817 theme.fg(Role::Foreground).patch(theme.bg(Role::Sidebar)),
818 ),
819 );
820 }
821 if self.placement.is_strip() && area.height >= 2 {
822 self.dock_tab_row(area, Some(theme)).plan(area).paint(buf);
823 }
824 if let Some(goal) = self.goal_text().filter(|_| layout.goal_height > 0) {
825 let goal = decorative(&goal, theme);
826 let receipt = self
827 .progress
828 .as_deref()
829 .filter(|_| {
830 WorkbarLayout::progress_shares_goal_row(
831 layout.body.width,
832 layout.goal_height > 0,
833 )
834 })
835 .map(|value| decorative(value, theme));
836 let reserved = receipt
837 .as_deref()
838 .map(|value| text::width(value) + 2)
839 .unwrap_or(0);
840 let width = usize::from(layout.content.width);
841 let goal =
842 text::truncate(&goal, width.saturating_sub(reserved), theme.ascii()).into_owned();
843 let mut spans = vec![Span::styled(
844 goal.clone(),
845 theme
846 .tui_ink(if self.goal_paused {
847 TuiInk::Warning
848 } else {
849 TuiInk::Working
850 })
851 .add_modifier(Modifier::BOLD),
852 )];
853 if let Some(receipt) = receipt {
854 let gap = width
855 .saturating_sub(text::width(&goal))
856 .saturating_sub(text::width(&receipt));
857 spans.push(Span::styled(
858 format!("{}{receipt}", " ".repeat(gap)),
859 theme.fg(Role::Muted),
860 ));
861 }
862 row(
863 Rect {
864 y: layout.body.y,
865 height: 1,
866 ..layout.content
867 },
868 buf,
869 &Line::from(spans),
870 );
871 }
872 if let Some(progress) = self
873 .progress
874 .as_deref()
875 .filter(|_| layout.progress_height > 0)
876 {
877 row(
878 Rect {
879 y: layout.body.y.saturating_add(layout.goal_height),
880 height: 1,
881 ..layout.content
882 },
883 buf,
884 &Line::styled(
885 text::truncate(
886 &decorative(progress, theme),
887 usize::from(layout.content.width),
888 theme.ascii(),
889 )
890 .into_owned(),
891 theme.fg(Role::Muted),
892 ),
893 );
894 }
895 let visible: Vec<_> = self
896 .rows
897 .iter()
898 .skip(layout.offset)
899 .take(layout.visible_rows)
900 .collect();
901 let identity_cap = usize::from(layout.content.width) * 2 / 5;
902 let identity_column = visible
903 .iter()
904 .map(|value| text::width(&agent_identity(value, identity_cap)))
905 .max()
906 .unwrap_or(0);
907 let status_column = visible
908 .iter()
909 .filter_map(|value| value.agent.as_ref())
910 .map(|facts| text::width(&safe(&facts.status)))
911 .max()
912 .unwrap_or(0);
913 let ordinal_count = self
914 .rows
915 .iter()
916 .filter(|value| value.id.starts_with("graph:"))
917 .count();
918 let ordinal_width = ordinal_count.max(1).to_string().len();
919 for (index, value) in visible.iter().enumerate() {
920 let target = Rect::new(
921 layout.content.x,
922 layout.content.y.saturating_add(index as u16),
923 layout.content.width,
924 1,
925 );
926 let ordinal = if self.placement.is_strip() && value.id.starts_with("graph:") {
927 let number = self
928 .rows
929 .iter()
930 .take(layout.offset + index + 1)
931 .filter(|value| value.id.starts_with("graph:"))
932 .count();
933 format!("{number:>ordinal_width$} · ")
934 } else {
935 String::new()
936 };
937 let mark = if self.opened.as_deref() == Some(&value.id) && value.selectable {
938 "▾"
939 } else {
940 &value.mark
941 };
942 let prefix = format!(
943 "{}{} ",
944 decorative(&ordinal, theme),
945 decorative(mark, theme)
946 );
947 let width = usize::from(target.width);
948 let style = self.row_style(value, theme);
949 if let Some(facts) = &value.agent {
950 let selected = self.focused && self.selected.as_deref() == Some(&value.id);
951 let mut normal = theme.fg(Role::Foreground).patch(theme.bg(if selected {
952 Role::Selected
953 } else if self.hovered.as_deref() == Some(&value.id) {
954 Role::Hover
955 } else {
956 Role::Sidebar
957 }));
958 let mut muted = theme.fg(Role::Muted).patch(normal);
959 // Secondary columns keep their own muted ink.
960 muted = muted.patch(theme.fg(Role::Muted));
961 if selected {
962 normal = normal.add_modifier(Modifier::BOLD);
963 muted = muted.add_modifier(Modifier::BOLD);
964 }
965 if self.opened.as_deref() == Some(&value.id) {
966 normal = normal
967 .patch(theme.fg(Role::Primary))
968 .add_modifier(Modifier::UNDERLINED);
969 muted = muted
970 .patch(theme.fg(Role::Primary))
971 .add_modifier(Modifier::UNDERLINED);
972 }
973 let columns = agent_columns(
974 facts,
975 width,
976 text::width(&prefix),
977 &agent_identity(value, identity_cap),
978 identity_column,
979 status_column,
980 theme,
981 );
982 row(
983 target,
984 buf,
985 &Line::from(vec![
986 Span::styled(prefix, normal),
987 Span::styled(columns.0, muted),
988 Span::styled(columns.1, normal),
989 Span::styled(columns.2, muted),
990 ]),
991 );
992 } else {
993 let label = decorative(&value.label, theme);
994 let label = text::truncate(
995 &label,
996 width.saturating_sub(text::width(&prefix)).max(1),
997 theme.ascii(),
998 );
999 let detail_candidate = if value.tone != WorkbarTone::Heading && target.width >= 44 {
1000 format!(" {}", decorative(&value.detail, theme))
1001 } else {
1002 String::new()
1003 };
1004 let detail_budget =
1005 width.saturating_sub(text::width(&prefix) + text::width(&label));
1006 let detail = if detail_budget >= 4 {
1007 text::truncate(&detail_candidate, detail_budget, theme.ascii()).into_owned()
1008 } else {
1009 String::new()
1010 };
1011 let gap = width.saturating_sub(
1012 text::width(&prefix) + text::width(&label) + text::width(&detail),
1013 );
1014 row(
1015 target,
1016 buf,
1017 &Line::styled(format!("{prefix}{label}{}{detail}", " ".repeat(gap)), style),
1018 );
1019 }
1020 }
1021 if visible.is_empty() && self.explicit && layout.content.height > 0 {
1022 row(
1023 Rect {
1024 height: 1,
1025 ..layout.content
1026 },
1027 buf,
1028 &Line::styled(
1029 text::truncate(
1030 &decorative(self.panel.empty_label(), theme),
1031 usize::from(layout.content.width),
1032 theme.ascii(),
1033 )
1034 .into_owned(),
1035 theme.fg(Role::Muted),
1036 ),
1037 );
1038 }
1039 if layout.more_row {
1040 let remaining = self
1041 .rows
1042 .len()
1043 .saturating_sub(layout.offset + visible.len());
1044 let value = if remaining == 0 {
1045 String::new()
1046 } else {
1047 format!("{} {remaining} more", if theme.ascii() { "v" } else { "↓" })
1048 };
1049 let value = text::truncate(&value, usize::from(layout.content.width), theme.ascii());
1050 let pad = usize::from(layout.content.width).saturating_sub(text::width(&value));
1051 row(
1052 Rect::new(
1053 layout.content.x,
1054 layout.content.bottom().saturating_sub(1),
1055 layout.content.width,
1056 1,
1057 ),
1058 buf,
1059 &Line::styled(format!("{}{value}", " ".repeat(pad)), theme.fg(Role::Muted)),
1060 );
1061 }
1062 let divider = self.divider(area);
1063 let mark = if self.placement.is_strip() {
1064 if theme.ascii() { "-" } else { "─" }
1065 } else if theme.ascii() {
1066 "|"
1067 } else {
1068 "│"
1069 };
1070 for y in divider.y..divider.bottom() {
1071 row(
1072 Rect::new(divider.x, y, divider.width, 1),
1073 buf,
1074 &Line::styled(
1075 mark.repeat(usize::from(divider.width)),
1076 theme.fg(Role::Border),
1077 ),
1078 );
1079 }
1080 if layout.overflow && layout.content.height > 0 && layout.body.width > 0 {
1081 WorkbarScrollbar {
1082 offset: layout.offset,
1083 visible: layout.visible_rows,
1084 total: self.rows.len(),
1085 thumb: if theme.ascii() { "|" } else { "┃" },
1086 track: if theme.ascii() { "|" } else { "│" },
1087 thumb_style: theme.tui_ink(TuiInk::Working),
1088 track_style: theme.fg(Role::Border),
1089 }
1090 .paint(
1091 Rect::new(
1092 layout.body.right().saturating_sub(1),
1093 layout.content.y,
1094 1,
1095 layout.content.height,
1096 ),
1097 buf,
1098 );
1099 }
1100 }
1101
1102 fn height(&self, width: u16, _theme: &Theme) -> u16 {
1103 self.requested_height(width)
1104 }
1105 }
1106
1107 fn safe(value: &str) -> String {
1108 text::display_safe(&value.replace(['\n', '\r', '\t'], " ")).into_owned()
1109 }
1110
1111 fn representable(area: Rect) -> Rect {
1112 Rect {
1113 width: area.width.min(u16::MAX.saturating_sub(area.x)),
1114 height: area.height.min(u16::MAX.saturating_sub(area.y)),
1115 ..area
1116 }
1117 }
1118
1119 fn decorative(value: &str, theme: &Theme) -> String {
1120 let value = safe(value);
1121 if !theme.ascii() {
1122 return value;
1123 }
1124 value
1125 .chars()
1126 .map(|value| {
1127 match value {
1128 '✎' => return "*".to_owned(),
1129 '⎇' => return "@".to_owned(),
1130 '◔' => return "o".to_owned(),
1131 '±' => return "+/-".to_owned(),
1132 '−' => return "-".to_owned(),
1133 _ => {}
1134 }
1135 let mut bytes = [0; 4];
1136 let glyph = value.encode_utf8(&mut bytes);
1137 glyphs::ascii_fallback(glyph)
1138 .map(str::to_owned)
1139 .unwrap_or_else(|| glyph.to_owned())
1140 })
1141 .collect()
1142 }
1143
1144 fn agent_identity(value: &WorkbarRow, cap: usize) -> String {
1145 let Some(facts) = &value.agent else {
1146 return String::new();
1147 };
1148 for candidate in [&value.label, &facts.role] {
1149 let candidate = safe(candidate);
1150 if !candidate.is_empty() && text::width(&candidate) <= cap {
1151 return candidate;
1152 }
1153 }
1154 String::new()
1155 }
1156
1157 fn elapsed(seconds: u64) -> String {
1158 if seconds >= 60 {
1159 format!("{}m {:02}s", seconds / 60, seconds % 60)
1160 } else {
1161 format!("{seconds}s")
1162 }
1163 }
1164
1165 fn tokens(value: u64) -> String {
1166 if value >= 1_000_000 {
1167 format!("{:.1}M", value as f64 / 1_000_000.0)
1168 } else if value >= 1_000 {
1169 format!("{:.1}k", value as f64 / 1_000.0)
1170 } else {
1171 value.to_string()
1172 }
1173 }
1174
1175 fn agent_receipt(facts: &WorkbarAgent, tier: usize, theme: &Theme) -> String {
1176 if tier >= 2 {
1177 return String::new();
1178 }
1179 let mut parts = Vec::new();
1180 if let Some(value) = &facts.model {
1181 let value = safe(value);
1182 if !value.is_empty() {
1183 parts.push(value);
1184 }
1185 }
1186 if let Some(value) = facts.elapsed_seconds {
1187 parts.push(elapsed(value));
1188 }
1189 if tier == 0
1190 && let Some(value) = facts.tokens
1191 {
1192 parts.push(format!(
1193 "{} {} tokens",
1194 if theme.ascii() { "v" } else { "↓" },
1195 tokens(value)
1196 ));
1197 }
1198 if let Some(value) = facts.remaining.filter(|value| *value > 0) {
1199 parts.push(format!("{value} left"));
1200 }
1201 parts.join(if theme.ascii() { " . " } else { " · " })
1202 }
1203
1204 fn agent_columns(
1205 facts: &WorkbarAgent,
1206 width: usize,
1207 prefix: usize,
1208 identity: &str,
1209 identity_column: usize,
1210 status_column: usize,
1211 theme: &Theme,
1212 ) -> (String, String, String) {
1213 for tier in 0..4 {
1214 let receipt = agent_receipt(facts, tier, theme);
1215 let identity = if tier == 3 || identity_column == 0 {
1216 String::new()
1217 } else {
1218 format!("{} ", text::pad(identity, identity_column, theme.ascii()))
1219 };
1220 let status = if tier == 3 || status_column == 0 {
1221 String::new()
1222 } else {
1223 format!(
1224 "{} ",
1225 text::pad(&safe(&facts.status), status_column, theme.ascii())
1226 )
1227 };
1228 let secondary = format!("{identity}{status}");
1229 let receipt_cost = if receipt.is_empty() {
1230 0
1231 } else {
1232 text::width(&receipt) + 2
1233 };
1234 let budget = width
1235 .saturating_sub(prefix)
1236 .saturating_sub(text::width(&secondary))
1237 .saturating_sub(receipt_cost);
1238 if budget < 24 && tier != 3 {
1239 continue;
1240 }
1241 let objective = safe(&facts.objective);
1242 let objective = text::truncate(&objective, budget, theme.ascii()).into_owned();
1243 let gap = width
1244 .saturating_sub(prefix)
1245 .saturating_sub(text::width(&secondary))
1246 .saturating_sub(text::width(&objective))
1247 .saturating_sub(text::width(&receipt));
1248 return (
1249 secondary,
1250 objective,
1251 format!("{}{receipt}", " ".repeat(gap)),
1252 );
1253 }
1254 (String::new(), String::new(), String::new())
1255 }
1256
1257 #[derive(Clone, Debug, PartialEq, Eq)]
1258 pub enum WorkbarOutcome {
1259 Ignored,
1260 Changed,
1261 Panel(WorkbarPanel),
1262 Activate(String),
1263 Close,
1264 ReleaseFocus,
1265 }
1266
1267 /// Optional native keyboard state. Enter returns a row id for host dispatch.
1268 #[derive(Clone, Debug, Default, PartialEq, Eq)]
1269 pub struct WorkbarState {
1270 pub panel: WorkbarPanel,
1271 pub selected: Option<String>,
1272 pub focused: bool,
1273 pub offset: usize,
1274 }
1275
1276 impl WorkbarState {
1277 #[must_use]
1278 pub fn apply(&self, mut workbar: Workbar) -> Workbar {
1279 workbar.panel = self.panel;
1280 workbar.selected = self.selected.clone();
1281 workbar.focused = self.focused;
1282 workbar.offset = self.offset;
1283 workbar
1284 }
1285
1286 pub fn handle_key(
1287 &mut self,
1288 key: KeyEvent,
1289 rows: &[WorkbarRow],
1290 visible_rows: usize,
1291 ) -> WorkbarOutcome {
1292 if key.kind == KeyEventKind::Release {
1293 return WorkbarOutcome::Ignored;
1294 }
1295 let control = key.modifiers.contains(KeyModifiers::CONTROL);
1296 if control
1297 && matches!(
1298 key.code,
1299 KeyCode::Tab | KeyCode::BackTab | KeyCode::Char(']')
1300 )
1301 {
1302 self.panel =
1303 if key.code == KeyCode::BackTab || key.modifiers.contains(KeyModifiers::SHIFT) {
1304 self.panel.prev()
1305 } else {
1306 self.panel.next()
1307 };
1308 self.selected = None;
1309 self.offset = 0;
1310 return WorkbarOutcome::Panel(self.panel);
1311 }
1312 if visible_rows == 0 {
1313 self.focused = false;
1314 return WorkbarOutcome::Ignored;
1315 }
1316 if !self.focused {
1317 if key.code == KeyCode::Char('w') && key.modifiers.contains(KeyModifiers::ALT) {
1318 self.focused = true;
1319 if !rows
1320 .iter()
1321 .any(|value| value.selectable && self.selected.as_deref() == Some(&value.id))
1322 {
1323 self.selected = rows
1324 .iter()
1325 .find(|value| value.selectable)
1326 .map(|value| value.id.clone());
1327 }
1328 return WorkbarOutcome::Changed;
1329 }
1330 return WorkbarOutcome::Ignored;
1331 }
1332 if matches!(key.code, KeyCode::Char(_))
1333 && !key
1334 .modifiers
1335 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
1336 {
1337 self.focused = false;
1338 return WorkbarOutcome::ReleaseFocus;
1339 }
1340 match key.code {
1341 KeyCode::Esc => {
1342 self.focused = false;
1343 self.selected = None;
1344 WorkbarOutcome::Close
1345 }
1346 KeyCode::Left | KeyCode::Right => {
1347 self.panel = if key.code == KeyCode::Left {
1348 self.panel.prev()
1349 } else {
1350 self.panel.next()
1351 };
1352 self.selected = None;
1353 self.offset = 0;
1354 WorkbarOutcome::Panel(self.panel)
1355 }
1356 KeyCode::Enter => self
1357 .selected
1358 .as_ref()
1359 .filter(|id| {
1360 rows.iter()
1361 .any(|value| &value.id == *id && value.selectable)
1362 })
1363 .map(|id| WorkbarOutcome::Activate(id.clone()))
1364 .unwrap_or(WorkbarOutcome::Ignored),
1365 KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End => {
1366 let selectable: Vec<_> = rows
1367 .iter()
1368 .enumerate()
1369 .filter(|(_, value)| value.selectable)
1370 .collect();
1371 if selectable.is_empty() {
1372 self.selected = None;
1373 return WorkbarOutcome::Ignored;
1374 }
1375 let current = selectable
1376 .iter()
1377 .position(|(_, value)| self.selected.as_deref() == Some(&value.id))
1378 .unwrap_or(0);
1379 let next = match key.code {
1380 KeyCode::Up => current.saturating_sub(1),
1381 KeyCode::Down => (current + 1).min(selectable.len() - 1),
1382 KeyCode::Home => 0,
1383 _ => selectable.len() - 1,
1384 };
1385 let (index, value) = selectable[next];
1386 self.selected = Some(value.id.clone());
1387 if index < self.offset {
1388 self.offset = index;
1389 } else if index >= self.offset.saturating_add(visible_rows.max(1)) {
1390 self.offset = index.saturating_add(1).saturating_sub(visible_rows.max(1));
1391 }
1392 WorkbarOutcome::Changed
1393 }
1394 KeyCode::PageUp => {
1395 self.offset = self.offset.saturating_sub(visible_rows.max(1));
1396 WorkbarOutcome::Changed
1397 }
1398 KeyCode::PageDown => {
1399 self.offset = self
1400 .offset
1401 .saturating_add(visible_rows.max(1))
1402 .min(rows.len().saturating_sub(visible_rows.max(1)));
1403 WorkbarOutcome::Changed
1404 }
1405 _ => WorkbarOutcome::Ignored,
1406 }
1407 }
1408 }
1409
1409 lines RUST