anveesa 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/tools.rs +6 -0
- package/src/tui.rs +199 -46
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
package/src/tools.rs
CHANGED
|
@@ -40,6 +40,12 @@ These actions can require the user to approve them, so explain what you intend t
|
|
|
40
40
|
" For any multi-step task, start by calling set_plan with a list of the steps you will take. \
|
|
41
41
|
After each step completes, call complete_task with the zero-based index of that step. \
|
|
42
42
|
Do not describe your plan in prose — use set_plan instead.",
|
|
43
|
+
);
|
|
44
|
+
text.push_str(
|
|
45
|
+
" CRITICAL — avoid redundant tool calls: All previous tool results are in your context. \
|
|
46
|
+
Do NOT re-read or re-list files and directories you have already inspected in this conversation. \
|
|
47
|
+
Before calling read_file or list_dir, check your conversation history first. \
|
|
48
|
+
Only call tools for information you do not yet have.",
|
|
43
49
|
);
|
|
44
50
|
text.push_str(
|
|
45
51
|
" If a tool call fails or a command times out, do NOT retry it automatically. \
|
package/src/tui.rs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
use std::{path::PathBuf, time::Duration};
|
|
1
|
+
use std::{path::PathBuf, time::{Duration, Instant}};
|
|
2
2
|
|
|
3
3
|
use anyhow::{Context, Result};
|
|
4
4
|
use crossterm::event::{
|
|
@@ -45,10 +45,11 @@ pub enum TuiEvent {
|
|
|
45
45
|
enum Msg {
|
|
46
46
|
User { text: String },
|
|
47
47
|
Assistant { text: String },
|
|
48
|
-
Tool { done: bool, ok: bool, text: String },
|
|
48
|
+
Tool { done: bool, ok: bool, text: String, elapsed_ms: Option<u128> },
|
|
49
49
|
FileOp { verb: String, path: String, added: usize, removed: usize, diff: Vec<(bool, String)> },
|
|
50
50
|
Error(String),
|
|
51
51
|
System(String),
|
|
52
|
+
Separator, // thin line between turns — "AI is done, your turn"
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
#[derive(Debug)]
|
|
@@ -83,6 +84,11 @@ pub struct App {
|
|
|
83
84
|
|
|
84
85
|
// pending turn tracking
|
|
85
86
|
pending_prompt: String,
|
|
87
|
+
streaming_started_at: Option<Instant>,
|
|
88
|
+
tool_started_at: Option<Instant>,
|
|
89
|
+
unread_count: usize,
|
|
90
|
+
// files/dirs already read this session — injected into workspace context each turn
|
|
91
|
+
seen_paths: std::collections::BTreeSet<String>,
|
|
86
92
|
|
|
87
93
|
// input
|
|
88
94
|
input: String,
|
|
@@ -166,6 +172,10 @@ impl App {
|
|
|
166
172
|
plan_tasks: vec![],
|
|
167
173
|
plan_done: vec![],
|
|
168
174
|
pending_prompt: String::new(),
|
|
175
|
+
streaming_started_at: None,
|
|
176
|
+
tool_started_at: None,
|
|
177
|
+
unread_count: 0,
|
|
178
|
+
seen_paths: std::collections::BTreeSet::new(),
|
|
169
179
|
|
|
170
180
|
input: String::new(),
|
|
171
181
|
input_cursor: 0,
|
|
@@ -330,6 +340,7 @@ fn handle_mouse(app: &mut App, kind: MouseEventKind) {
|
|
|
330
340
|
app.scroll = app.scroll.saturating_add(3);
|
|
331
341
|
if app.scroll >= app.total_lines {
|
|
332
342
|
app.auto_scroll = true;
|
|
343
|
+
app.unread_count = 0;
|
|
333
344
|
}
|
|
334
345
|
}
|
|
335
346
|
_ => {}
|
|
@@ -530,6 +541,7 @@ fn handle_slash_command(app: &mut App, text: &str) -> bool {
|
|
|
530
541
|
app.accumulated_response.clear();
|
|
531
542
|
app.usage = Usage::default();
|
|
532
543
|
app.pending_image = None;
|
|
544
|
+
app.seen_paths.clear();
|
|
533
545
|
app.input.clear();
|
|
534
546
|
app.input_cursor = 0;
|
|
535
547
|
if let Some(path) = &app.session_path {
|
|
@@ -682,7 +694,11 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
|
|
|
682
694
|
let config = app.config.clone();
|
|
683
695
|
let options = app.options.clone();
|
|
684
696
|
let history = app.history.clone();
|
|
685
|
-
|
|
697
|
+
// Augment workspace context with already-seen paths so the model doesn't re-scan them
|
|
698
|
+
let workspace_context = augmented_workspace_context(
|
|
699
|
+
app.workspace_context.as_deref(),
|
|
700
|
+
&app.seen_paths,
|
|
701
|
+
);
|
|
686
702
|
let policy = app.policy;
|
|
687
703
|
let mcp_arc = app.mcp.clone();
|
|
688
704
|
let tui_tx = app.stream_tx.clone();
|
|
@@ -750,23 +766,32 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
|
|
|
750
766
|
async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
751
767
|
match ev {
|
|
752
768
|
TuiEvent::Token(text) => {
|
|
769
|
+
if app.streaming_started_at.is_none() {
|
|
770
|
+
app.streaming_started_at = Some(Instant::now());
|
|
771
|
+
}
|
|
753
772
|
app.streaming_buf.push_str(&text);
|
|
754
|
-
app.auto_scroll
|
|
773
|
+
if app.auto_scroll {
|
|
774
|
+
app.scroll = usize::MAX;
|
|
775
|
+
} else {
|
|
776
|
+
app.unread_count += 1;
|
|
777
|
+
}
|
|
755
778
|
}
|
|
756
779
|
TuiEvent::Status(msg) => {
|
|
757
780
|
app.tool_status = msg;
|
|
758
781
|
}
|
|
759
782
|
TuiEvent::ToolCall(summary) => {
|
|
760
783
|
flush_streaming_buf(app);
|
|
761
|
-
// Commit any previous pending tool (shouldn't happen, but be safe)
|
|
762
784
|
commit_pending_tool(app, true);
|
|
763
785
|
app.pending_tool = Some(PendingTool { summary: summary.clone() });
|
|
786
|
+
app.tool_started_at = Some(Instant::now());
|
|
764
787
|
app.tool_status = summary;
|
|
765
788
|
}
|
|
766
789
|
TuiEvent::ToolDone { summary, ok } => {
|
|
767
|
-
|
|
790
|
+
let elapsed_ms = app.tool_started_at.take().map(|t| t.elapsed().as_millis());
|
|
791
|
+
// Record the inspected path so we can tell the model what it already knows
|
|
792
|
+
record_seen_path(&mut app.seen_paths, &summary);
|
|
768
793
|
app.pending_tool = Some(PendingTool { summary });
|
|
769
|
-
|
|
794
|
+
commit_pending_tool_timed(app, ok, elapsed_ms);
|
|
770
795
|
app.tool_status = "Thinking".to_string();
|
|
771
796
|
}
|
|
772
797
|
TuiEvent::FileOp { verb, path, added, removed, diff } => {
|
|
@@ -804,6 +829,39 @@ async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
|
804
829
|
}
|
|
805
830
|
}
|
|
806
831
|
|
|
832
|
+
/// Extract a path from a tool call summary string and record it as "already seen".
|
|
833
|
+
fn record_seen_path(seen: &mut std::collections::BTreeSet<String>, summary: &str) {
|
|
834
|
+
// Summaries look like "read file src/foo.ts" or "list directory src/bar"
|
|
835
|
+
// or "git status", "web search `...`" — only record file/dir paths
|
|
836
|
+
for prefix in &["read file ", "list directory "] {
|
|
837
|
+
if let Some(path) = summary.strip_prefix(prefix) {
|
|
838
|
+
let path = path.trim().to_string();
|
|
839
|
+
if !path.is_empty() {
|
|
840
|
+
seen.insert(path);
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/// Build an augmented workspace context that includes already-seen paths.
|
|
848
|
+
fn augmented_workspace_context(
|
|
849
|
+
base: Option<&str>,
|
|
850
|
+
seen: &std::collections::BTreeSet<String>,
|
|
851
|
+
) -> Option<String> {
|
|
852
|
+
if seen.is_empty() {
|
|
853
|
+
return base.map(str::to_string);
|
|
854
|
+
}
|
|
855
|
+
let seen_note = format!(
|
|
856
|
+
"\nAlready inspected this session (do NOT re-read these):\n{}",
|
|
857
|
+
seen.iter().map(|p| format!(" - {p}")).collect::<Vec<_>>().join("\n")
|
|
858
|
+
);
|
|
859
|
+
Some(match base {
|
|
860
|
+
Some(b) => format!("{b}{seen_note}"),
|
|
861
|
+
None => seen_note,
|
|
862
|
+
})
|
|
863
|
+
}
|
|
864
|
+
|
|
807
865
|
/// Flush streaming_buf to messages and accumulated_response.
|
|
808
866
|
fn flush_streaming_buf(app: &mut App) {
|
|
809
867
|
if !app.streaming_buf.is_empty() {
|
|
@@ -815,8 +873,13 @@ fn flush_streaming_buf(app: &mut App) {
|
|
|
815
873
|
|
|
816
874
|
/// Commit a pending tool call to the message history with its final status.
|
|
817
875
|
fn commit_pending_tool(app: &mut App, ok: bool) {
|
|
876
|
+
let elapsed = app.tool_started_at.take().map(|t| t.elapsed().as_millis());
|
|
877
|
+
commit_pending_tool_timed(app, ok, elapsed);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
fn commit_pending_tool_timed(app: &mut App, ok: bool, elapsed_ms: Option<u128>) {
|
|
818
881
|
if let Some(tool) = app.pending_tool.take() {
|
|
819
|
-
app.messages.push(Msg::Tool { done: true, ok, text: tool.summary });
|
|
882
|
+
app.messages.push(Msg::Tool { done: true, ok, text: tool.summary, elapsed_ms });
|
|
820
883
|
}
|
|
821
884
|
}
|
|
822
885
|
|
|
@@ -840,6 +903,12 @@ fn finish_turn(app: &mut App) {
|
|
|
840
903
|
}
|
|
841
904
|
app.mode = Mode::Input;
|
|
842
905
|
app.tool_status.clear();
|
|
906
|
+
app.streaming_started_at = None;
|
|
907
|
+
app.tool_started_at = None;
|
|
908
|
+
// Add a separator so the user sees clearly the AI is done
|
|
909
|
+
if !app.history.is_empty() {
|
|
910
|
+
app.messages.push(Msg::Separator);
|
|
911
|
+
}
|
|
843
912
|
}
|
|
844
913
|
|
|
845
914
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
@@ -871,11 +940,11 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
871
940
|
String::new()
|
|
872
941
|
};
|
|
873
942
|
let left = format!(" anveesa v{version}{token_str}");
|
|
874
|
-
let right = format!("{} · {}
|
|
943
|
+
let right = format!(" {} · {} ", app.provider, app.model);
|
|
875
944
|
let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
|
|
876
945
|
let title = format!("{left}{}{right}", " ".repeat(gap));
|
|
877
946
|
frame.render_widget(
|
|
878
|
-
Paragraph::new(title).style(Style::default().fg(Color::
|
|
947
|
+
Paragraph::new(title).style(Style::default().fg(Color::Rgb(20, 20, 30)).bg(Color::Rgb(97, 175, 239))),
|
|
879
948
|
area,
|
|
880
949
|
);
|
|
881
950
|
}
|
|
@@ -900,7 +969,7 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
900
969
|
}
|
|
901
970
|
lines.push(Line::from(""));
|
|
902
971
|
}
|
|
903
|
-
Msg::Tool { done, ok, text } => {
|
|
972
|
+
Msg::Tool { done, ok, text, elapsed_ms } => {
|
|
904
973
|
let (icon, color) = if !done {
|
|
905
974
|
("⠋", Color::DarkGray)
|
|
906
975
|
} else if *ok {
|
|
@@ -908,10 +977,15 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
908
977
|
} else {
|
|
909
978
|
("✗", Color::Rgb(224, 108, 117))
|
|
910
979
|
};
|
|
911
|
-
|
|
912
|
-
format!(" {
|
|
913
|
-
|
|
914
|
-
|
|
980
|
+
let elapsed_str = match elapsed_ms {
|
|
981
|
+
Some(ms) if *ms < 1000 => format!(" {ms}ms"),
|
|
982
|
+
Some(ms) => format!(" {:.1}s", *ms as f64 / 1000.0),
|
|
983
|
+
None => String::new(),
|
|
984
|
+
};
|
|
985
|
+
lines.push(Line::from(vec![
|
|
986
|
+
Span::styled(format!(" {icon} {text}"), Style::default().fg(color)),
|
|
987
|
+
Span::styled(elapsed_str, Style::default().fg(Color::Rgb(80, 80, 100))),
|
|
988
|
+
]));
|
|
915
989
|
lines.push(Line::from(""));
|
|
916
990
|
}
|
|
917
991
|
Msg::FileOp { verb, path, added, removed, diff } => {
|
|
@@ -959,21 +1033,36 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
959
1033
|
}
|
|
960
1034
|
lines.push(Line::from(""));
|
|
961
1035
|
}
|
|
1036
|
+
Msg::Separator => {
|
|
1037
|
+
// Thin line between turns — signals "AI is done, your turn"
|
|
1038
|
+
let line_width = width.saturating_sub(2);
|
|
1039
|
+
lines.push(Line::from(Span::styled(
|
|
1040
|
+
format!(" {}", "─".repeat(line_width.min(60))),
|
|
1041
|
+
Style::default().fg(Color::Rgb(45, 45, 65)),
|
|
1042
|
+
)));
|
|
1043
|
+
lines.push(Line::from(""));
|
|
1044
|
+
}
|
|
962
1045
|
}
|
|
963
1046
|
}
|
|
964
1047
|
|
|
965
|
-
// Live pending tool (running, not yet committed)
|
|
1048
|
+
// Live pending tool (running, not yet committed) — animated with elapsed time
|
|
966
1049
|
if let Some(tool) = &app.pending_tool {
|
|
967
1050
|
let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
968
1051
|
let dot = dots[app.spinner_frame % dots.len()];
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
)));
|
|
1052
|
+
let elapsed = app.tool_started_at
|
|
1053
|
+
.map(|t| t.elapsed().as_secs_f32())
|
|
1054
|
+
.unwrap_or(0.0);
|
|
1055
|
+
let elapsed_str = if elapsed < 0.5 { String::new() } else { format!(" ({:.1}s)", elapsed) };
|
|
1056
|
+
lines.push(Line::from(vec![
|
|
1057
|
+
Span::styled(
|
|
1058
|
+
format!(" {dot} {}{}", tool.summary, elapsed_str),
|
|
1059
|
+
Style::default().fg(Color::Rgb(180, 140, 60)),
|
|
1060
|
+
),
|
|
1061
|
+
]));
|
|
973
1062
|
lines.push(Line::from(""));
|
|
974
1063
|
}
|
|
975
1064
|
|
|
976
|
-
// In-progress streaming
|
|
1065
|
+
// In-progress streaming — assistant message being built token by token
|
|
977
1066
|
if !app.streaming_buf.is_empty() || (app.mode == Mode::Streaming && app.pending_tool.is_none()) {
|
|
978
1067
|
lines.push(assistant_header(&app.model));
|
|
979
1068
|
if !app.streaming_buf.is_empty() {
|
|
@@ -983,10 +1072,14 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
983
1072
|
} else {
|
|
984
1073
|
let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
985
1074
|
let dot = dots[app.spinner_frame % dots.len()];
|
|
986
|
-
let
|
|
1075
|
+
let elapsed = app.streaming_started_at
|
|
1076
|
+
.map(|t| t.elapsed().as_secs_f32())
|
|
1077
|
+
.unwrap_or(0.0);
|
|
1078
|
+
let elapsed_str = if elapsed < 0.5 { String::new() } else { format!(" ({:.1}s)", elapsed) };
|
|
1079
|
+
let status = if app.tool_status.is_empty() { "Thinking" } else { app.tool_status.as_str() };
|
|
987
1080
|
lines.push(Line::from(Span::styled(
|
|
988
|
-
format!(" {dot} {status}"),
|
|
989
|
-
Style::default().fg(Color::
|
|
1081
|
+
format!(" {dot} {status}{elapsed_str}"),
|
|
1082
|
+
Style::default().fg(Color::Rgb(180, 140, 60)),
|
|
990
1083
|
)));
|
|
991
1084
|
}
|
|
992
1085
|
lines.push(Line::from(""));
|
|
@@ -1002,8 +1095,21 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
1002
1095
|
};
|
|
1003
1096
|
app.scroll = scroll;
|
|
1004
1097
|
|
|
1098
|
+
// "↓ unread" badge overlay when scrolled away
|
|
1099
|
+
let mut widget_lines = lines;
|
|
1100
|
+
if !app.auto_scroll && app.unread_count > 0 {
|
|
1101
|
+
let badge = format!(" ↓ {} new ", app.unread_count);
|
|
1102
|
+
widget_lines.push(Line::from(Span::styled(
|
|
1103
|
+
badge,
|
|
1104
|
+
Style::default()
|
|
1105
|
+
.fg(Color::Black)
|
|
1106
|
+
.bg(Color::Rgb(97, 175, 239))
|
|
1107
|
+
.add_modifier(Modifier::BOLD),
|
|
1108
|
+
)));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1005
1111
|
frame.render_widget(
|
|
1006
|
-
Paragraph::new(
|
|
1112
|
+
Paragraph::new(widget_lines).scroll((scroll as u16, 0)),
|
|
1007
1113
|
area,
|
|
1008
1114
|
);
|
|
1009
1115
|
}
|
|
@@ -1023,12 +1129,34 @@ fn assistant_header(model: &str) -> Line<'static> {
|
|
|
1023
1129
|
}
|
|
1024
1130
|
|
|
1025
1131
|
fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
1132
|
+
// Border color reflects mode: ready=green, streaming=yellow, confirming=orange
|
|
1133
|
+
let border_color = match app.mode {
|
|
1134
|
+
Mode::Input => Color::Rgb(152, 195, 121), // green — "your turn"
|
|
1135
|
+
Mode::Streaming => Color::Rgb(229, 192, 123), // yellow — "thinking"
|
|
1136
|
+
Mode::Confirming=> Color::Rgb(224, 108, 117), // red — "needs decision"
|
|
1137
|
+
};
|
|
1026
1138
|
let block = Block::default()
|
|
1027
1139
|
.borders(Borders::TOP)
|
|
1028
|
-
.border_style(Style::default().fg(
|
|
1140
|
+
.border_style(Style::default().fg(border_color));
|
|
1029
1141
|
let inner = block.inner(area);
|
|
1030
1142
|
frame.render_widget(block, area);
|
|
1031
1143
|
|
|
1144
|
+
if app.mode != Mode::Input {
|
|
1145
|
+
// Don't show cursor or text while AI is working
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if app.input.is_empty() && app.pending_image.is_none() {
|
|
1150
|
+
// Placeholder hint
|
|
1151
|
+
frame.render_widget(
|
|
1152
|
+
Paragraph::new(" ❯ Ask anything… (↑/↓ history · Ctrl+V paste image)")
|
|
1153
|
+
.style(Style::default().fg(Color::Rgb(60, 60, 80))),
|
|
1154
|
+
inner,
|
|
1155
|
+
);
|
|
1156
|
+
frame.set_cursor_position((inner.x + 4, inner.y));
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1032
1160
|
let label = if app.pending_image.is_some() { " [📎] ❯ " } else { " ❯ " };
|
|
1033
1161
|
let label_w = label.chars().count();
|
|
1034
1162
|
let display = format!("{label}{}", app.input);
|
|
@@ -1038,7 +1166,6 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
1038
1166
|
inner,
|
|
1039
1167
|
);
|
|
1040
1168
|
|
|
1041
|
-
// Position cursor
|
|
1042
1169
|
let cursor_chars = label_w + app.input[..app.input_cursor].chars().count();
|
|
1043
1170
|
let w = inner.width.max(1) as usize;
|
|
1044
1171
|
frame.set_cursor_position((
|
|
@@ -1048,25 +1175,51 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
1048
1175
|
}
|
|
1049
1176
|
|
|
1050
1177
|
fn render_status(frame: &mut Frame, area: Rect, app: &App) {
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
format!(" ⚠
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
.
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1178
|
+
match app.mode {
|
|
1179
|
+
Mode::Confirming => {
|
|
1180
|
+
let summary = app.confirm.as_ref().map(|c| c.summary.as_str()).unwrap_or("?");
|
|
1181
|
+
let text = format!(" ⚠ {summary} [y] allow once [a] allow all [n] deny ");
|
|
1182
|
+
frame.render_widget(
|
|
1183
|
+
Paragraph::new(text)
|
|
1184
|
+
.style(Style::default().fg(Color::Black).bg(Color::Rgb(224, 108, 117))),
|
|
1185
|
+
area,
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
Mode::Streaming => {
|
|
1189
|
+
let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1190
|
+
let dot = dots[app.spinner_frame % dots.len()];
|
|
1191
|
+
let elapsed = app.streaming_started_at
|
|
1192
|
+
.map(|t| t.elapsed().as_secs_f32())
|
|
1193
|
+
.unwrap_or(0.0);
|
|
1194
|
+
let state = if !app.tool_status.is_empty() {
|
|
1195
|
+
format!("{dot} {} ({:.1}s)", app.tool_status, elapsed)
|
|
1196
|
+
} else {
|
|
1197
|
+
format!("{dot} Thinking… ({:.1}s)", elapsed)
|
|
1198
|
+
};
|
|
1199
|
+
let left = format!(" {state}");
|
|
1200
|
+
let right = format!(" {} Ctrl+C cancel ", app.cwd);
|
|
1201
|
+
let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
|
|
1202
|
+
let text = format!("{left}{}{right}", " ".repeat(gap));
|
|
1203
|
+
frame.render_widget(
|
|
1204
|
+
Paragraph::new(text)
|
|
1205
|
+
.style(Style::default().fg(Color::Rgb(229, 192, 123)).bg(Color::Rgb(30, 28, 20))),
|
|
1206
|
+
area,
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
Mode::Input => {
|
|
1210
|
+
let mode_icon = if app.mouse_capture { "⊙" } else { "⊕" };
|
|
1211
|
+
let mode_label = if app.mouse_capture { "scroll" } else { "select" };
|
|
1212
|
+
let left = format!(" ● Ready {}", app.cwd);
|
|
1213
|
+
let right = format!(" {mode_icon} {mode_label} /help ");
|
|
1214
|
+
let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
|
|
1215
|
+
let text = format!("{left}{}{right}", " ".repeat(gap));
|
|
1216
|
+
frame.render_widget(
|
|
1217
|
+
Paragraph::new(text)
|
|
1218
|
+
.style(Style::default().fg(Color::Rgb(152, 195, 121)).bg(Color::Rgb(20, 30, 20))),
|
|
1219
|
+
area,
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1070
1223
|
}
|
|
1071
1224
|
|
|
1072
1225
|
// ── Text formatting ───────────────────────────────────────────────────────────
|