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 CHANGED
@@ -60,7 +60,7 @@ dependencies = [
60
60
 
61
61
  [[package]]
62
62
  name = "anveesa"
63
- version = "0.4.2"
63
+ version = "0.4.4"
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.4"
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.4",
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/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
- let workspace_context = app.workspace_context.clone();
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 = true;
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
- // Commit the pending tool with its final status
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
- commit_pending_tool(app, ok);
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!("{} · {} ", app.provider, app.model);
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::Black).bg(Color::Rgb(97, 175, 239))),
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
- lines.push(Line::from(Span::styled(
912
- format!(" {icon} {text}"),
913
- Style::default().fg(color),
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
- lines.push(Line::from(Span::styled(
970
- format!(" {dot} {}", tool.summary),
971
- Style::default().fg(Color::DarkGray),
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 status = if app.tool_status.is_empty() { "Thinking" } else { &app.tool_status };
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::DarkGray),
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(lines).scroll((scroll as u16, 0)),
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(Color::Rgb(60, 60, 80)));
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
- 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);
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 ───────────────────────────────────────────────────────────