anveesa 0.4.2 → 0.4.3
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/tui.rs +155 -45
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
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,9 @@ 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, // messages added while scrolled away
|
|
86
90
|
|
|
87
91
|
// input
|
|
88
92
|
input: String,
|
|
@@ -166,6 +170,9 @@ impl App {
|
|
|
166
170
|
plan_tasks: vec![],
|
|
167
171
|
plan_done: vec![],
|
|
168
172
|
pending_prompt: String::new(),
|
|
173
|
+
streaming_started_at: None,
|
|
174
|
+
tool_started_at: None,
|
|
175
|
+
unread_count: 0,
|
|
169
176
|
|
|
170
177
|
input: String::new(),
|
|
171
178
|
input_cursor: 0,
|
|
@@ -330,6 +337,7 @@ fn handle_mouse(app: &mut App, kind: MouseEventKind) {
|
|
|
330
337
|
app.scroll = app.scroll.saturating_add(3);
|
|
331
338
|
if app.scroll >= app.total_lines {
|
|
332
339
|
app.auto_scroll = true;
|
|
340
|
+
app.unread_count = 0;
|
|
333
341
|
}
|
|
334
342
|
}
|
|
335
343
|
_ => {}
|
|
@@ -750,23 +758,30 @@ async fn submit_prompt(app: &mut App, text: String) -> Result<()> {
|
|
|
750
758
|
async fn handle_stream_event(app: &mut App, ev: TuiEvent) {
|
|
751
759
|
match ev {
|
|
752
760
|
TuiEvent::Token(text) => {
|
|
761
|
+
if app.streaming_started_at.is_none() {
|
|
762
|
+
app.streaming_started_at = Some(Instant::now());
|
|
763
|
+
}
|
|
753
764
|
app.streaming_buf.push_str(&text);
|
|
754
|
-
app.auto_scroll
|
|
765
|
+
if app.auto_scroll {
|
|
766
|
+
app.scroll = usize::MAX;
|
|
767
|
+
} else {
|
|
768
|
+
app.unread_count += 1;
|
|
769
|
+
}
|
|
755
770
|
}
|
|
756
771
|
TuiEvent::Status(msg) => {
|
|
757
772
|
app.tool_status = msg;
|
|
758
773
|
}
|
|
759
774
|
TuiEvent::ToolCall(summary) => {
|
|
760
775
|
flush_streaming_buf(app);
|
|
761
|
-
// Commit any previous pending tool (shouldn't happen, but be safe)
|
|
762
776
|
commit_pending_tool(app, true);
|
|
763
777
|
app.pending_tool = Some(PendingTool { summary: summary.clone() });
|
|
778
|
+
app.tool_started_at = Some(Instant::now());
|
|
764
779
|
app.tool_status = summary;
|
|
765
780
|
}
|
|
766
781
|
TuiEvent::ToolDone { summary, ok } => {
|
|
767
|
-
|
|
782
|
+
let elapsed_ms = app.tool_started_at.take().map(|t| t.elapsed().as_millis());
|
|
768
783
|
app.pending_tool = Some(PendingTool { summary });
|
|
769
|
-
|
|
784
|
+
commit_pending_tool_timed(app, ok, elapsed_ms);
|
|
770
785
|
app.tool_status = "Thinking".to_string();
|
|
771
786
|
}
|
|
772
787
|
TuiEvent::FileOp { verb, path, added, removed, diff } => {
|
|
@@ -815,8 +830,13 @@ fn flush_streaming_buf(app: &mut App) {
|
|
|
815
830
|
|
|
816
831
|
/// Commit a pending tool call to the message history with its final status.
|
|
817
832
|
fn commit_pending_tool(app: &mut App, ok: bool) {
|
|
833
|
+
let elapsed = app.tool_started_at.take().map(|t| t.elapsed().as_millis());
|
|
834
|
+
commit_pending_tool_timed(app, ok, elapsed);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
fn commit_pending_tool_timed(app: &mut App, ok: bool, elapsed_ms: Option<u128>) {
|
|
818
838
|
if let Some(tool) = app.pending_tool.take() {
|
|
819
|
-
app.messages.push(Msg::Tool { done: true, ok, text: tool.summary });
|
|
839
|
+
app.messages.push(Msg::Tool { done: true, ok, text: tool.summary, elapsed_ms });
|
|
820
840
|
}
|
|
821
841
|
}
|
|
822
842
|
|
|
@@ -840,6 +860,12 @@ fn finish_turn(app: &mut App) {
|
|
|
840
860
|
}
|
|
841
861
|
app.mode = Mode::Input;
|
|
842
862
|
app.tool_status.clear();
|
|
863
|
+
app.streaming_started_at = None;
|
|
864
|
+
app.tool_started_at = None;
|
|
865
|
+
// Add a separator so the user sees clearly the AI is done
|
|
866
|
+
if !app.history.is_empty() {
|
|
867
|
+
app.messages.push(Msg::Separator);
|
|
868
|
+
}
|
|
843
869
|
}
|
|
844
870
|
|
|
845
871
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
@@ -871,11 +897,11 @@ fn render_header(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
871
897
|
String::new()
|
|
872
898
|
};
|
|
873
899
|
let left = format!(" anveesa v{version}{token_str}");
|
|
874
|
-
let right = format!("{} · {}
|
|
900
|
+
let right = format!(" {} · {} ", app.provider, app.model);
|
|
875
901
|
let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
|
|
876
902
|
let title = format!("{left}{}{right}", " ".repeat(gap));
|
|
877
903
|
frame.render_widget(
|
|
878
|
-
Paragraph::new(title).style(Style::default().fg(Color::
|
|
904
|
+
Paragraph::new(title).style(Style::default().fg(Color::Rgb(20, 20, 30)).bg(Color::Rgb(97, 175, 239))),
|
|
879
905
|
area,
|
|
880
906
|
);
|
|
881
907
|
}
|
|
@@ -900,7 +926,7 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
900
926
|
}
|
|
901
927
|
lines.push(Line::from(""));
|
|
902
928
|
}
|
|
903
|
-
Msg::Tool { done, ok, text } => {
|
|
929
|
+
Msg::Tool { done, ok, text, elapsed_ms } => {
|
|
904
930
|
let (icon, color) = if !done {
|
|
905
931
|
("⠋", Color::DarkGray)
|
|
906
932
|
} else if *ok {
|
|
@@ -908,10 +934,15 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
908
934
|
} else {
|
|
909
935
|
("✗", Color::Rgb(224, 108, 117))
|
|
910
936
|
};
|
|
911
|
-
|
|
912
|
-
format!(" {
|
|
913
|
-
|
|
914
|
-
|
|
937
|
+
let elapsed_str = match elapsed_ms {
|
|
938
|
+
Some(ms) if *ms < 1000 => format!(" {ms}ms"),
|
|
939
|
+
Some(ms) => format!(" {:.1}s", *ms as f64 / 1000.0),
|
|
940
|
+
None => String::new(),
|
|
941
|
+
};
|
|
942
|
+
lines.push(Line::from(vec![
|
|
943
|
+
Span::styled(format!(" {icon} {text}"), Style::default().fg(color)),
|
|
944
|
+
Span::styled(elapsed_str, Style::default().fg(Color::Rgb(80, 80, 100))),
|
|
945
|
+
]));
|
|
915
946
|
lines.push(Line::from(""));
|
|
916
947
|
}
|
|
917
948
|
Msg::FileOp { verb, path, added, removed, diff } => {
|
|
@@ -959,21 +990,36 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
959
990
|
}
|
|
960
991
|
lines.push(Line::from(""));
|
|
961
992
|
}
|
|
993
|
+
Msg::Separator => {
|
|
994
|
+
// Thin line between turns — signals "AI is done, your turn"
|
|
995
|
+
let line_width = width.saturating_sub(2);
|
|
996
|
+
lines.push(Line::from(Span::styled(
|
|
997
|
+
format!(" {}", "─".repeat(line_width.min(60))),
|
|
998
|
+
Style::default().fg(Color::Rgb(45, 45, 65)),
|
|
999
|
+
)));
|
|
1000
|
+
lines.push(Line::from(""));
|
|
1001
|
+
}
|
|
962
1002
|
}
|
|
963
1003
|
}
|
|
964
1004
|
|
|
965
|
-
// Live pending tool (running, not yet committed)
|
|
1005
|
+
// Live pending tool (running, not yet committed) — animated with elapsed time
|
|
966
1006
|
if let Some(tool) = &app.pending_tool {
|
|
967
1007
|
let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
968
1008
|
let dot = dots[app.spinner_frame % dots.len()];
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
)));
|
|
1009
|
+
let elapsed = app.tool_started_at
|
|
1010
|
+
.map(|t| t.elapsed().as_secs_f32())
|
|
1011
|
+
.unwrap_or(0.0);
|
|
1012
|
+
let elapsed_str = if elapsed < 0.5 { String::new() } else { format!(" ({:.1}s)", elapsed) };
|
|
1013
|
+
lines.push(Line::from(vec![
|
|
1014
|
+
Span::styled(
|
|
1015
|
+
format!(" {dot} {}{}", tool.summary, elapsed_str),
|
|
1016
|
+
Style::default().fg(Color::Rgb(180, 140, 60)),
|
|
1017
|
+
),
|
|
1018
|
+
]));
|
|
973
1019
|
lines.push(Line::from(""));
|
|
974
1020
|
}
|
|
975
1021
|
|
|
976
|
-
// In-progress streaming
|
|
1022
|
+
// In-progress streaming — assistant message being built token by token
|
|
977
1023
|
if !app.streaming_buf.is_empty() || (app.mode == Mode::Streaming && app.pending_tool.is_none()) {
|
|
978
1024
|
lines.push(assistant_header(&app.model));
|
|
979
1025
|
if !app.streaming_buf.is_empty() {
|
|
@@ -983,10 +1029,14 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
983
1029
|
} else {
|
|
984
1030
|
let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
985
1031
|
let dot = dots[app.spinner_frame % dots.len()];
|
|
986
|
-
let
|
|
1032
|
+
let elapsed = app.streaming_started_at
|
|
1033
|
+
.map(|t| t.elapsed().as_secs_f32())
|
|
1034
|
+
.unwrap_or(0.0);
|
|
1035
|
+
let elapsed_str = if elapsed < 0.5 { String::new() } else { format!(" ({:.1}s)", elapsed) };
|
|
1036
|
+
let status = if app.tool_status.is_empty() { "Thinking" } else { app.tool_status.as_str() };
|
|
987
1037
|
lines.push(Line::from(Span::styled(
|
|
988
|
-
format!(" {dot} {status}"),
|
|
989
|
-
Style::default().fg(Color::
|
|
1038
|
+
format!(" {dot} {status}{elapsed_str}"),
|
|
1039
|
+
Style::default().fg(Color::Rgb(180, 140, 60)),
|
|
990
1040
|
)));
|
|
991
1041
|
}
|
|
992
1042
|
lines.push(Line::from(""));
|
|
@@ -1002,8 +1052,21 @@ fn render_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
|
|
1002
1052
|
};
|
|
1003
1053
|
app.scroll = scroll;
|
|
1004
1054
|
|
|
1055
|
+
// "↓ unread" badge overlay when scrolled away
|
|
1056
|
+
let mut widget_lines = lines;
|
|
1057
|
+
if !app.auto_scroll && app.unread_count > 0 {
|
|
1058
|
+
let badge = format!(" ↓ {} new ", app.unread_count);
|
|
1059
|
+
widget_lines.push(Line::from(Span::styled(
|
|
1060
|
+
badge,
|
|
1061
|
+
Style::default()
|
|
1062
|
+
.fg(Color::Black)
|
|
1063
|
+
.bg(Color::Rgb(97, 175, 239))
|
|
1064
|
+
.add_modifier(Modifier::BOLD),
|
|
1065
|
+
)));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1005
1068
|
frame.render_widget(
|
|
1006
|
-
Paragraph::new(
|
|
1069
|
+
Paragraph::new(widget_lines).scroll((scroll as u16, 0)),
|
|
1007
1070
|
area,
|
|
1008
1071
|
);
|
|
1009
1072
|
}
|
|
@@ -1023,12 +1086,34 @@ fn assistant_header(model: &str) -> Line<'static> {
|
|
|
1023
1086
|
}
|
|
1024
1087
|
|
|
1025
1088
|
fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
1089
|
+
// Border color reflects mode: ready=green, streaming=yellow, confirming=orange
|
|
1090
|
+
let border_color = match app.mode {
|
|
1091
|
+
Mode::Input => Color::Rgb(152, 195, 121), // green — "your turn"
|
|
1092
|
+
Mode::Streaming => Color::Rgb(229, 192, 123), // yellow — "thinking"
|
|
1093
|
+
Mode::Confirming=> Color::Rgb(224, 108, 117), // red — "needs decision"
|
|
1094
|
+
};
|
|
1026
1095
|
let block = Block::default()
|
|
1027
1096
|
.borders(Borders::TOP)
|
|
1028
|
-
.border_style(Style::default().fg(
|
|
1097
|
+
.border_style(Style::default().fg(border_color));
|
|
1029
1098
|
let inner = block.inner(area);
|
|
1030
1099
|
frame.render_widget(block, area);
|
|
1031
1100
|
|
|
1101
|
+
if app.mode != Mode::Input {
|
|
1102
|
+
// Don't show cursor or text while AI is working
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if app.input.is_empty() && app.pending_image.is_none() {
|
|
1107
|
+
// Placeholder hint
|
|
1108
|
+
frame.render_widget(
|
|
1109
|
+
Paragraph::new(" ❯ Ask anything… (↑/↓ history · Ctrl+V paste image)")
|
|
1110
|
+
.style(Style::default().fg(Color::Rgb(60, 60, 80))),
|
|
1111
|
+
inner,
|
|
1112
|
+
);
|
|
1113
|
+
frame.set_cursor_position((inner.x + 4, inner.y));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1032
1117
|
let label = if app.pending_image.is_some() { " [📎] ❯ " } else { " ❯ " };
|
|
1033
1118
|
let label_w = label.chars().count();
|
|
1034
1119
|
let display = format!("{label}{}", app.input);
|
|
@@ -1038,7 +1123,6 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
1038
1123
|
inner,
|
|
1039
1124
|
);
|
|
1040
1125
|
|
|
1041
|
-
// Position cursor
|
|
1042
1126
|
let cursor_chars = label_w + app.input[..app.input_cursor].chars().count();
|
|
1043
1127
|
let w = inner.width.max(1) as usize;
|
|
1044
1128
|
frame.set_cursor_position((
|
|
@@ -1048,25 +1132,51 @@ fn render_input(frame: &mut Frame, area: Rect, app: &App) {
|
|
|
1048
1132
|
}
|
|
1049
1133
|
|
|
1050
1134
|
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
|
-
|
|
1135
|
+
match app.mode {
|
|
1136
|
+
Mode::Confirming => {
|
|
1137
|
+
let summary = app.confirm.as_ref().map(|c| c.summary.as_str()).unwrap_or("?");
|
|
1138
|
+
let text = format!(" ⚠ {summary} [y] allow once [a] allow all [n] deny ");
|
|
1139
|
+
frame.render_widget(
|
|
1140
|
+
Paragraph::new(text)
|
|
1141
|
+
.style(Style::default().fg(Color::Black).bg(Color::Rgb(224, 108, 117))),
|
|
1142
|
+
area,
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
Mode::Streaming => {
|
|
1146
|
+
let dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1147
|
+
let dot = dots[app.spinner_frame % dots.len()];
|
|
1148
|
+
let elapsed = app.streaming_started_at
|
|
1149
|
+
.map(|t| t.elapsed().as_secs_f32())
|
|
1150
|
+
.unwrap_or(0.0);
|
|
1151
|
+
let state = if !app.tool_status.is_empty() {
|
|
1152
|
+
format!("{dot} {} ({:.1}s)", app.tool_status, elapsed)
|
|
1153
|
+
} else {
|
|
1154
|
+
format!("{dot} Thinking… ({:.1}s)", elapsed)
|
|
1155
|
+
};
|
|
1156
|
+
let left = format!(" {state}");
|
|
1157
|
+
let right = format!(" {} Ctrl+C cancel ", app.cwd);
|
|
1158
|
+
let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
|
|
1159
|
+
let text = format!("{left}{}{right}", " ".repeat(gap));
|
|
1160
|
+
frame.render_widget(
|
|
1161
|
+
Paragraph::new(text)
|
|
1162
|
+
.style(Style::default().fg(Color::Rgb(229, 192, 123)).bg(Color::Rgb(30, 28, 20))),
|
|
1163
|
+
area,
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
Mode::Input => {
|
|
1167
|
+
let mode_icon = if app.mouse_capture { "⊙" } else { "⊕" };
|
|
1168
|
+
let mode_label = if app.mouse_capture { "scroll" } else { "select" };
|
|
1169
|
+
let left = format!(" ● Ready {}", app.cwd);
|
|
1170
|
+
let right = format!(" {mode_icon} {mode_label} /help ");
|
|
1171
|
+
let gap = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count());
|
|
1172
|
+
let text = format!("{left}{}{right}", " ".repeat(gap));
|
|
1173
|
+
frame.render_widget(
|
|
1174
|
+
Paragraph::new(text)
|
|
1175
|
+
.style(Style::default().fg(Color::Rgb(152, 195, 121)).bg(Color::Rgb(20, 30, 20))),
|
|
1176
|
+
area,
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1070
1180
|
}
|
|
1071
1181
|
|
|
1072
1182
|
// ── Text formatting ───────────────────────────────────────────────────────────
|