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 CHANGED
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.4.2"
63
+ version = "0.4.3"
64
64
  dependencies = [
65
65
  "anyhow",
66
66
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  edition = "2024"
5
5
  default-run = "anveesa"
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anveesa",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "A terminal CLI that wraps AI providers (OpenAI-compatible APIs and local CLIs) into a single unified command",
5
5
  "main": "bin/anveesa.js",
6
6
  "bin": {
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 = true;
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
- // Commit the pending tool with its final status
782
+ let elapsed_ms = app.tool_started_at.take().map(|t| t.elapsed().as_millis());
768
783
  app.pending_tool = Some(PendingTool { summary });
769
- commit_pending_tool(app, ok);
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!("{} · {} ", app.provider, app.model);
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::Black).bg(Color::Rgb(97, 175, 239))),
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
- lines.push(Line::from(Span::styled(
912
- format!(" {icon} {text}"),
913
- Style::default().fg(color),
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
- lines.push(Line::from(Span::styled(
970
- format!(" {dot} {}", tool.summary),
971
- Style::default().fg(Color::DarkGray),
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 status = if app.tool_status.is_empty() { "Thinking" } else { &app.tool_status };
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::DarkGray),
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(lines).scroll((scroll as u16, 0)),
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(Color::Rgb(60, 60, 80)));
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
- let (text, style) = if app.mode == Mode::Confirming {
1052
- let summary = app.confirm.as_ref().map(|c| c.summary.as_str()).unwrap_or("?");
1053
- (
1054
- format!(" ⚠ Allow: {summary} [y]es [a]ll [n]o "),
1055
- Style::default().fg(Color::Black).bg(Color::Rgb(229, 192, 123)),
1056
- )
1057
- } else {
1058
- let mode_tag = if app.mouse_capture { "[scroll]" } else { "[select]" };
1059
- let hints = "Ctrl+M mode · /copy · /help";
1060
- let left = format!(" {} {mode_tag}", app.cwd);
1061
- let right = format!("{hints} ");
1062
- let gap = (area.width as usize)
1063
- .saturating_sub(left.chars().count() + right.chars().count());
1064
- (
1065
- format!("{left}{}{right}", " ".repeat(gap)),
1066
- Style::default().fg(Color::DarkGray).bg(Color::Rgb(30, 30, 46)),
1067
- )
1068
- };
1069
- frame.render_widget(Paragraph::new(text).style(style), area);
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 ───────────────────────────────────────────────────────────