返回 CodeWhale
views.rs
根目录 / crates / tui / src / tui / work_surface / views.rs
1 //! Row builders for the dock views that are not work rows: files, notepad,
2 //! context, git, and price. Each returns ordinary [`WorkRow`]s so the one
3 //! row/hitbox pipeline in `render/` paints, selects, and clicks them exactly
4 //! like a to-do or a sub-agent — a view is a subset of one row grammar, not
5 //! a second widget system.
6
7 use crate::agent_roster::format_tokens;
8 use crate::tui::app::{App, SidebarRowAction};
9
10 use super::model::{RailPanel, WorkRow, WorkRowId, WorkTone};
11
12 /// Views whose tab always shows once the dock is up: the fact views have
13 /// something to say in every session, so they are always one click away.
14 pub(super) const fn view_always_has_content(panel: RailPanel) -> bool {
15 matches!(
16 panel,
17 RailPanel::Context | RailPanel::Git | RailPanel::Price
18 )
19 }
20
21 pub(super) fn files_touched_count(_app: &mut App) -> usize {
22 0
23 }
24
25 pub(super) fn notepad_has_text(_app: &App) -> bool {
26 false
27 }
28
29 pub(super) fn files_rows(_app: &mut App) -> Vec<WorkRow> {
30 Vec::new()
31 }
32
33 pub(super) fn notepad_rows(_app: &mut App) -> Vec<WorkRow> {
34 Vec::new()
35 }
36
37 /// The context view: the budget, not a fact list. Used/limit and the
38 /// compaction threshold from the same snapshot the footer meter reads, a
39 /// gauge, then the breakdown the accounting can already give per frame —
40 /// system prompt, tool schemas, conversation, tool output, files read — and
41 /// the one action that exists as a command, `/compact`.
42 pub(super) fn context_rows(app: &mut App) -> Vec<WorkRow> {
43 let Some((used, max, percent)) = crate::tui::ui::context_usage_snapshot(app) else {
44 return Vec::new();
45 };
46 let used = u64::try_from(used).unwrap_or(0);
47 let threshold = app.auto_compact_threshold_percent.round().clamp(0.0, 100.0) as u8;
48 let fact = |id: &str, label: String, detail: &str| WorkRow {
49 id: WorkRowId(format!("context:{id}")),
50 mark: "·",
51 label,
52 detail: detail.to_string(),
53 tone: WorkTone::Muted,
54 selectable: false,
55 primary_action: None,
56 agent: None,
57 };
58 let mut out = vec![WorkRow {
59 id: WorkRowId("context:budget".to_string()),
60 mark: "◔",
61 label: format!(
62 "{} of {} · {}% · compacts at {threshold}%",
63 format_tokens(used),
64 format_tokens(u64::from(max)),
65 percent.round() as u8
66 ),
67 detail: "/context for the full source map".to_string(),
68 tone: if percent >= f64::from(threshold) {
69 WorkTone::Attention
70 } else {
71 WorkTone::Live
72 },
73 selectable: true,
74 primary_action: Some(SidebarRowAction::Command("/context".to_string())),
75 agent: None,
76 }];
77 out.push(fact("gauge", gauge(percent, 24), ""));
78
79 // Breakdown from what is already counted per frame: the system prompt
80 // estimate the footer meter uses and the per-message token cache. No
81 // text is re-scanned here; a message is tool output when every block in
82 // it is a tool result.
83 let system_tokens =
84 crate::compaction::estimate_input_tokens_conservative(&[], app.system_prompt.as_ref());
85 let tools = app
86 .session
87 .last_tool_catalog
88 .as_ref()
89 .map(|catalog| catalog.len())
90 .unwrap_or(0);
91 let (conversation, tool_output, messages) = message_split(app);
92 out.push(fact(
93 "system",
94 format!(
95 "system + tools · {} · {tools} tools",
96 format_tokens(system_tokens as u64)
97 ),
98 "",
99 ));
100 out.push(fact(
101 "conversation",
102 format!(
103 "conversation · {messages} · {}",
104 format_tokens(conversation)
105 ),
106 "",
107 ));
108 out.push(fact(
109 "tool-output",
110 format!("tool output · {}", format_tokens(tool_output)),
111 "",
112 ));
113 let read = super::model::settled_file_activity(app).read;
114 if !read.is_empty() {
115 out.push(fact(
116 "files",
117 format!("files read · {}", read.len()),
118 &read.join(", "),
119 ));
120 }
121 out.push(WorkRow {
122 id: WorkRowId("context:compact".to_string()),
123 mark: "▸",
124 label: "compact now".to_string(),
125 detail: "/compact".to_string(),
126 tone: WorkTone::Live,
127 selectable: true,
128 primary_action: Some(SidebarRowAction::Command("/compact".to_string())),
129 agent: None,
130 });
131 app.work_surface.latest_rows = out.clone();
132 out
133 }
134
135 /// `████████░░░░░░░░` — `width` cells, filled to `percent`.
136 fn gauge(percent: f64, width: usize) -> String {
137 let filled = ((percent / 100.0) * width as f64)
138 .round()
139 .clamp(0.0, width as f64) as usize;
140 let mut bar = String::with_capacity(width * 3);
141 for _ in 0..filled {
142 bar.push('█');
143 }
144 for _ in filled..width {
145 bar.push('░');
146 }
147 bar
148 }
149
150 /// `(conversation tokens, tool-output tokens, message count)` from the
151 /// per-message estimate cache the meter already maintains. Messages the
152 /// cache has not seen yet count as zero rather than being re-estimated on
153 /// the render path.
154 fn message_split(app: &App) -> (u64, u64, usize) {
155 let cache = app.context_token_cache.borrow();
156 let mut conversation = 0u64;
157 let mut tool_output = 0u64;
158 for (index, message) in app.api_messages.iter().enumerate() {
159 let tokens = cache
160 .message_tokens
161 .get(index)
162 .map(|tokens| (*tokens as u64).saturating_mul(3).div_ceil(2))
163 .unwrap_or(0);
164 let all_tool_results = !message.content.is_empty()
165 && message.content.iter().all(|block| {
166 matches!(
167 block,
168 codewhale_models::ContentBlock::ToolResult { .. }
169 | codewhale_models::ContentBlock::ToolSearchToolResult { .. }
170 | codewhale_models::ContentBlock::CodeExecutionToolResult { .. }
171 )
172 });
173 if all_tool_results {
174 tool_output = tool_output.saturating_add(tokens);
175 } else {
176 conversation = conversation.saturating_add(tokens);
177 }
178 }
179 (conversation, tool_output, app.api_messages.len())
180 }
181
182 pub(super) fn git_rows(_app: &mut App) -> Vec<WorkRow> {
183 Vec::new()
184 }
185
186 /// The price view. One number everywhere: the session total is the same
187 /// `displayed_session_cost_for_currency` the footer chip prints, through the
188 /// same `App::format_cost_amount`; per-agent rows come from the roster's
189 /// usage receipts (`cost_microusd`, absent = no receipt, never `0`).
190 pub(super) fn price_rows(app: &mut App) -> Vec<WorkRow> {
191 let mut out = Vec::new();
192 let session = app.session_cost_label();
193 out.push(WorkRow {
194 id: WorkRowId("price:session".to_string()),
195 mark: "$",
196 label: format!("session · {session}"),
197 detail: "/cost for the full ledger".to_string(),
198 tone: WorkTone::Live,
199 selectable: true,
200 primary_action: Some(SidebarRowAction::Command("/cost".to_string())),
201 agent: None,
202 });
203 let roster = app.current_agent_roster().to_vec();
204 let priced: Vec<_> = roster
205 .iter()
206 .filter(|row| row.cost_microusd.is_some())
207 .collect();
208 if !priced.is_empty() {
209 let total = priced
210 .iter()
211 .filter_map(|row| row.cost_microusd)
212 .fold(0u64, u64::saturating_add);
213 out.push(WorkRow {
214 id: WorkRowId("price:agents".to_string()),
215 mark: "·",
216 label: format!(
217 "agents · {}",
218 app.format_cost_amount(total as f64 / 1_000_000.0)
219 ),
220 detail: format!("{} of {} agents priced", priced.len(), roster.len()),
221 tone: WorkTone::Muted,
222 selectable: false,
223 primary_action: None,
224 agent: None,
225 });
226 for row in priced {
227 let cost = row.cost_microusd.unwrap_or(0) as f64 / 1_000_000.0;
228 out.push(WorkRow {
229 id: WorkRowId(format!("price:agent:{}", row.worker_id)),
230 mark: row.state.glyph(),
231 label: format!(" {} · {}", row.display_name, app.format_cost_amount(cost)),
232 detail: row.model.clone(),
233 tone: WorkTone::Muted,
234 selectable: true,
235 primary_action: Some(SidebarRowAction::OpenAgentDetail {
236 agent_id: row.worker_id.clone(),
237 }),
238 agent: None,
239 });
240 }
241 }
242 let metrics = crate::tui::session_metrics::snapshot_from_app(app);
243 if let Some(pct) = metrics.cache_hit_percent {
244 out.push(WorkRow {
245 id: WorkRowId("price:cache".to_string()),
246 mark: "·",
247 label: format!("cache hit · {pct}%"),
248 detail: "/cache for per-turn cache telemetry".to_string(),
249 tone: WorkTone::Muted,
250 selectable: true,
251 primary_action: Some(SidebarRowAction::Command("/cache".to_string())),
252 agent: None,
253 });
254 }
255 if let Some(rate) = crate::pricing::model_rate_label(
256 app.api_provider,
257 &app.model,
258 app.cost_display_currency(app.cost_currency),
259 ) {
260 out.push(WorkRow {
261 id: WorkRowId("price:rate".to_string()),
262 mark: "·",
263 label: format!("{} · {rate}", app.model),
264 detail: "per million tokens, in / out".to_string(),
265 tone: WorkTone::Muted,
266 selectable: false,
267 primary_action: None,
268 agent: None,
269 });
270 }
271 app.work_surface.latest_rows = out.clone();
272 out
273 }
274
274 lines RUST