anveesa 0.3.5 → 0.3.7

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/src/lib.rs CHANGED
@@ -2,6 +2,7 @@ pub mod cli;
2
2
  pub mod config;
3
3
  pub mod provider;
4
4
  pub mod tools;
5
+ pub mod tui;
5
6
 
6
7
  use std::{
7
8
  fs,
@@ -24,8 +25,8 @@ use crate::{
24
25
  set_default_provider,
25
26
  },
26
27
  provider::{
27
- ApprovalDecision, ApprovalPolicy, ChatMessage, DiffKind, ImageAttachment, PromptRequest,
28
- StreamEvent, ToolConfirmPreview, TurnResult, Usage,
28
+ ApprovalDecision, ApprovalPolicy, ChatMessage, ChatRole, DiffKind, ImageAttachment,
29
+ PromptRequest, StreamEvent, ToolConfirmPreview, TurnResult, Usage,
29
30
  },
30
31
  };
31
32
 
@@ -81,7 +82,8 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
81
82
  .get(&provider_name)
82
83
  .with_context(|| format!("unknown provider '{provider_name}'"))?;
83
84
  let _tools_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
84
- let mut images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
85
+ let images_available = matches!(provider, ProviderConfig::OpenAiCompatible(_));
86
+ let mut images_available = images_available;
85
87
  let model = options
86
88
  .model
87
89
  .clone()
@@ -123,7 +125,24 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
123
125
  let session_saved_at = loaded_session.as_ref().filter(|s| s.saved_at > 0).map(|s| s.saved_at);
124
126
  // tracks the most recent successful save this run — kept fresh for /session display
125
127
  let mut last_saved_at: u64 = session_saved_at.unwrap_or(0);
128
+ // Per-project system prompt: load .anveesa from cwd if no --system was given.
129
+ if session_options.system.is_none() {
130
+ if let Ok(text) = fs::read_to_string(cwd.join(".anveesa")) {
131
+ let trimmed = text.trim().to_string();
132
+ if !trimmed.is_empty() {
133
+ session_options.system = Some(trimmed);
134
+ }
135
+ }
136
+ }
137
+
126
138
  let history_path = repl_history_path();
139
+ // Load prompt history for ↑/↓ recall (one entry per line, newest at end).
140
+ let input_history: Vec<String> = history_path
141
+ .as_deref()
142
+ .and_then(|p| fs::read_to_string(p).ok())
143
+ .map(|c| c.lines().filter(|l| !l.is_empty()).map(String::from).collect())
144
+ .unwrap_or_default();
145
+
127
146
  print_session_header(
128
147
  &provider_name,
129
148
  session_options.model.as_deref().unwrap_or("-"),
@@ -133,6 +152,45 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
133
152
  );
134
153
 
135
154
  let is_tty = io::stdout().is_terminal();
155
+
156
+ // ── TUI mode ──────────────────────────────────────────────────────────────
157
+ if is_tty {
158
+ // Spawn a background task to read keyboard events (crossterm::event::read is blocking).
159
+ let (key_tx, key_rx) = tokio::sync::mpsc::unbounded_channel();
160
+ tokio::task::spawn_blocking(move || {
161
+ loop {
162
+ match crossterm::event::read() {
163
+ Ok(ev) => { if key_tx.send(ev).is_err() { break; } }
164
+ Err(_) => break,
165
+ }
166
+ }
167
+ });
168
+
169
+ let short_cwd = std::env::var("HOME")
170
+ .map(|h| cwd.display().to_string().replacen(&h, "~", 1))
171
+ .unwrap_or_else(|_| cwd.display().to_string());
172
+
173
+ let app = tui::App::new(
174
+ provider_name.clone(),
175
+ session_options.model.clone().unwrap_or_else(|| "-".to_string()),
176
+ short_cwd,
177
+ history,
178
+ images_available,
179
+ session_path.clone(),
180
+ last_saved_at,
181
+ input_history,
182
+ config,
183
+ session_options,
184
+ workspace_context,
185
+ policy,
186
+ key_rx,
187
+ );
188
+
189
+ tui::run(app).await?;
190
+ return Ok(());
191
+ }
192
+ // ── Fallback: plain REPL (non-TTY / piped) ────────────────────────────────
193
+
136
194
  let width = term_width();
137
195
  let label = prompt_label(is_tty);
138
196
  // Fingerprint of the last clipboard image we attached — prevents re-attaching
@@ -144,7 +202,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
144
202
  loop {
145
203
  print_input_separator(is_tty, width);
146
204
  let (line, ctrl_v_image) =
147
- match read_prompt_line(&label, width, &mut paste_count, images_available) {
205
+ match read_prompt_line(&label, width, &mut paste_count, images_available, &input_history) {
148
206
  Ok(PromptRead::Line(line, img)) => (line, img),
149
207
  Ok(PromptRead::Interrupted) => continue,
150
208
  Ok(PromptRead::Eof) => {
@@ -197,6 +255,25 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
197
255
  );
198
256
  continue;
199
257
  }
258
+ s if s.starts_with("/export") => {
259
+ let arg = s.strip_prefix("/export").unwrap().trim();
260
+ let path = if arg.is_empty() {
261
+ cwd.join(format!("anveesa-export-{}.md", unix_now()))
262
+ } else {
263
+ std::path::PathBuf::from(arg)
264
+ };
265
+ match export_conversation(&path, &history) {
266
+ Ok(()) => {
267
+ if is_tty {
268
+ eprintln!("\x1b[2m Exported to {}\x1b[0m", path.display());
269
+ } else {
270
+ println!("exported to {}", path.display());
271
+ }
272
+ }
273
+ Err(e) => eprintln!("\x1b[1;31m✗\x1b[0m {e:#}"),
274
+ }
275
+ continue;
276
+ }
200
277
  "/status" => {
201
278
  print_status_inline(
202
279
  is_tty,
@@ -1107,8 +1184,9 @@ fn print_status_inline(
1107
1184
 
1108
1185
  fn print_help_inline(is_tty: bool) {
1109
1186
  if !is_tty {
1110
- println!("commands: /clear, /session, /attach [path], /exit, /quit, /help");
1111
- println!("images: copy an image (Cmd+C), then send a message to auto-attach it");
1187
+ println!("commands: /clear, /export [path], /session, /attach [path], /exit, /quit, /help");
1188
+ println!("keys: ↑/↓ history ←/→ cursor Home/End Ctrl+W delete-word Ctrl+U clear-line");
1189
+ println!("images: Ctrl+V to paste clipboard image, or copy then send to auto-attach");
1112
1190
  return;
1113
1191
  }
1114
1192
  println!();
@@ -1116,6 +1194,7 @@ fn print_help_inline(is_tty: bool) {
1116
1194
  println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1117
1195
  println!(" \x1b[1;32m/status\x1b[0m provider, model, turns, token usage");
1118
1196
  println!(" \x1b[1;32m/session\x1b[0m show session file, age, and turn count");
1197
+ println!(" \x1b[1;32m/export\x1b[0m \x1b[2m[path]\x1b[0m save conversation to a markdown file");
1119
1198
  println!(" \x1b[1;32m/model\x1b[0m \x1b[2m[name]\x1b[0m switch or show current model");
1120
1199
  println!(" \x1b[1;32m/provider\x1b[0m \x1b[2m[name]\x1b[0m switch or show current provider");
1121
1200
  println!(" \x1b[1;32m/clear\x1b[0m reset conversation and delete saved session");
@@ -1123,9 +1202,19 @@ fn print_help_inline(is_tty: bool) {
1123
1202
  println!(" \x1b[1;32m/exit\x1b[0m, \x1b[1;32m/quit\x1b[0m leave the session");
1124
1203
  println!(" \x1b[1;32m/help\x1b[0m show this message");
1125
1204
  println!();
1205
+ println!("\x1b[2m Keyboard\x1b[0m");
1206
+ println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1207
+ println!(" \x1b[2m↑ / ↓\x1b[0m recall previous / next prompt");
1208
+ println!(" \x1b[2m← / →\x1b[0m move cursor left / right");
1209
+ println!(" \x1b[2mHome / End\x1b[0m jump to start / end of line");
1210
+ println!(" \x1b[2mCtrl+W\x1b[0m delete word before cursor");
1211
+ println!(" \x1b[2mCtrl+U\x1b[0m clear entire line \x1b[2m(also Cmd+Delete)\x1b[0m");
1212
+ println!(" \x1b[2mCtrl+V\x1b[0m paste image from clipboard");
1213
+ println!();
1126
1214
  println!("\x1b[2m Images\x1b[0m");
1127
1215
  println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1128
- println!(" Cmd+C an image, then send a message it attaches automatically.");
1216
+ println!(" \x1b[2mCtrl+V\x1b[0m to paste a clipboard image inline (shows \x1b[2m[📎]\x1b[0m indicator).");
1217
+ println!(" Or Cmd+C an image and send any message — it attaches automatically.");
1129
1218
  println!(" Or use \x1b[1;32m/attach\x1b[0m \x1b[2mpath/to/file.png\x1b[0m for a specific file.");
1130
1219
  println!(" For broadest clipboard support: \x1b[2mbrew install pngpaste\x1b[0m");
1131
1220
  println!();
@@ -1167,6 +1256,26 @@ fn print_session_info(is_tty: bool, path: Option<&Path>, turns: usize, saved_at:
1167
1256
  println!();
1168
1257
  }
1169
1258
 
1259
+ pub fn export_conversation(path: &std::path::Path, history: &[ChatMessage]) -> Result<()> {
1260
+ let mut out = String::new();
1261
+ for msg in history {
1262
+ match msg.role {
1263
+ ChatRole::User => {
1264
+ out.push_str("## You\n\n");
1265
+ out.push_str(&msg.content);
1266
+ out.push_str("\n\n");
1267
+ }
1268
+ ChatRole::Assistant => {
1269
+ out.push_str("## Assistant\n\n");
1270
+ out.push_str(&msg.content);
1271
+ out.push_str("\n\n");
1272
+ }
1273
+ }
1274
+ }
1275
+ fs::write(path, out.trim_end())
1276
+ .with_context(|| format!("failed to write {}", path.display()))
1277
+ }
1278
+
1170
1279
  fn list_providers() -> Result<()> {
1171
1280
  let config = AppConfig::load()?;
1172
1281
  let is_tty = io::stdout().is_terminal();
@@ -1336,6 +1445,8 @@ struct PromptBuffer {
1336
1445
  full: String,
1337
1446
  display: String,
1338
1447
  segments: Vec<PromptSegment>,
1448
+ /// Byte offset into `full` — where the next insertion goes.
1449
+ cursor: usize,
1339
1450
  }
1340
1451
 
1341
1452
  impl PromptBuffer {
@@ -1343,28 +1454,63 @@ impl PromptBuffer {
1343
1454
  self.full.is_empty()
1344
1455
  }
1345
1456
 
1346
- fn push_text(&mut self, text: &str) {
1347
- self.full.push_str(text);
1348
- self.display.push_str(text);
1349
-
1350
- if let Some(segment) = self.segments.last_mut()
1351
- && !segment.hidden
1352
- {
1353
- segment.full.push_str(text);
1354
- segment.display.push_str(text);
1355
- return;
1457
+ /// Char offset in `display` that corresponds to the current cursor position in `full`.
1458
+ /// Used to position the terminal cursor after a redraw.
1459
+ fn display_cursor_char(&self) -> usize {
1460
+ let mut full_pos = 0usize;
1461
+ let mut disp_chars = 0usize;
1462
+ for seg in &self.segments {
1463
+ let seg_len = seg.full.len();
1464
+ let next_pos = full_pos + seg_len;
1465
+ if self.cursor <= next_pos {
1466
+ let offset = self.cursor - full_pos;
1467
+ return if seg.hidden {
1468
+ // Hidden spans are atomic: cursor snaps to end of placeholder.
1469
+ disp_chars + seg.display.chars().count()
1470
+ } else {
1471
+ disp_chars + seg.full[..offset].chars().count()
1472
+ };
1473
+ }
1474
+ full_pos = next_pos;
1475
+ disp_chars += seg.display.chars().count();
1356
1476
  }
1477
+ disp_chars
1478
+ }
1357
1479
 
1358
- self.segments.push(PromptSegment {
1359
- full: text.to_string(),
1360
- display: text.to_string(),
1361
- hidden: false,
1362
- });
1480
+ fn push_text(&mut self, text: &str) {
1481
+ // Find the segment containing the cursor and insert there.
1482
+ let mut pos = 0usize;
1483
+ for seg in self.segments.iter_mut() {
1484
+ let seg_len = seg.full.len();
1485
+ if !seg.hidden && self.cursor >= pos && self.cursor <= pos + seg_len {
1486
+ let offset = self.cursor - pos;
1487
+ seg.full.insert_str(offset, text);
1488
+ seg.display.insert_str(offset, text);
1489
+ self.cursor += text.len();
1490
+ self.rebuild_flat();
1491
+ return;
1492
+ }
1493
+ pos += seg_len;
1494
+ }
1495
+ // Cursor is at end or after a hidden segment — append to last visible segment.
1496
+ if let Some(seg) = self.segments.last_mut().filter(|s| !s.hidden) {
1497
+ seg.full.push_str(text);
1498
+ seg.display.push_str(text);
1499
+ } else {
1500
+ self.segments.push(PromptSegment {
1501
+ full: text.to_string(),
1502
+ display: text.to_string(),
1503
+ hidden: false,
1504
+ });
1505
+ }
1506
+ self.cursor += text.len();
1507
+ self.rebuild_flat();
1363
1508
  }
1364
1509
 
1365
1510
  fn push_hidden_paste(&mut self, text: String, display: String) {
1366
1511
  self.full.push_str(&text);
1367
1512
  self.display.push_str(&display);
1513
+ self.cursor = self.full.len();
1368
1514
  self.segments.push(PromptSegment {
1369
1515
  full: text,
1370
1516
  display,
@@ -1372,48 +1518,118 @@ impl PromptBuffer {
1372
1518
  });
1373
1519
  }
1374
1520
 
1375
- fn pop_last(&mut self) {
1376
- let Some(segment) = self.segments.last_mut() else {
1521
+ /// Delete the character immediately before the cursor.
1522
+ /// Deletes the entire span atomically if the cursor is just past a hidden span.
1523
+ fn delete_before_cursor(&mut self) {
1524
+ if self.cursor == 0 {
1377
1525
  return;
1378
- };
1526
+ }
1527
+ let mut pos = 0usize;
1528
+ let mut remove_idx: Option<usize> = None;
1529
+ for (i, seg) in self.segments.iter_mut().enumerate() {
1530
+ let seg_len = seg.full.len();
1531
+ let next_pos = pos + seg_len;
1532
+ if seg.hidden && next_pos == self.cursor {
1533
+ // cursor is right after a hidden span — delete the whole span
1534
+ self.cursor -= seg_len;
1535
+ remove_idx = Some(i);
1536
+ break;
1537
+ }
1538
+ if !seg.hidden && self.cursor > pos && self.cursor <= next_pos {
1539
+ let offset = self.cursor - pos;
1540
+ if let Some(ch) = seg.full[..offset].chars().next_back() {
1541
+ let ch_len = ch.len_utf8();
1542
+ seg.full.drain((offset - ch_len)..offset);
1543
+ seg.display.drain((offset - ch_len)..offset);
1544
+ self.cursor -= ch_len;
1545
+ if seg.full.is_empty() {
1546
+ remove_idx = Some(i);
1547
+ }
1548
+ }
1549
+ break;
1550
+ }
1551
+ pos = next_pos;
1552
+ }
1553
+ if let Some(i) = remove_idx {
1554
+ self.segments.remove(i);
1555
+ }
1556
+ self.rebuild_flat();
1557
+ }
1379
1558
 
1380
- if segment.hidden {
1381
- let full_len = segment.full.len();
1382
- let display_len = segment.display.len();
1383
- self.full.truncate(self.full.len().saturating_sub(full_len));
1384
- self.display
1385
- .truncate(self.display.len().saturating_sub(display_len));
1386
- self.segments.pop();
1559
+ fn move_left(&mut self) {
1560
+ if self.cursor == 0 {
1387
1561
  return;
1388
1562
  }
1563
+ let mut pos = 0usize;
1564
+ for seg in &self.segments {
1565
+ let next_pos = pos + seg.full.len();
1566
+ if seg.hidden && next_pos == self.cursor {
1567
+ self.cursor = pos;
1568
+ return;
1569
+ }
1570
+ if !seg.hidden && self.cursor > pos && self.cursor <= next_pos {
1571
+ let offset = self.cursor - pos;
1572
+ if let Some(ch) = seg.full[..offset].chars().next_back() {
1573
+ self.cursor -= ch.len_utf8();
1574
+ }
1575
+ return;
1576
+ }
1577
+ pos = next_pos;
1578
+ }
1579
+ }
1389
1580
 
1390
- let _ = segment.full.pop();
1391
- let _ = segment.display.pop();
1392
- let _ = self.full.pop();
1393
- let _ = self.display.pop();
1394
-
1395
- if segment.full.is_empty() {
1396
- self.segments.pop();
1581
+ fn move_right(&mut self) {
1582
+ if self.cursor >= self.full.len() {
1583
+ return;
1584
+ }
1585
+ let mut pos = 0usize;
1586
+ for seg in &self.segments {
1587
+ let seg_len = seg.full.len();
1588
+ if seg.hidden && pos == self.cursor {
1589
+ self.cursor += seg_len;
1590
+ return;
1591
+ }
1592
+ if !seg.hidden && self.cursor >= pos && self.cursor < pos + seg_len {
1593
+ let offset = self.cursor - pos;
1594
+ if let Some(ch) = seg.full[offset..].chars().next() {
1595
+ self.cursor += ch.len_utf8();
1596
+ }
1597
+ return;
1598
+ }
1599
+ pos += seg_len;
1397
1600
  }
1398
1601
  }
1399
1602
 
1603
+ fn move_home(&mut self) {
1604
+ self.cursor = 0;
1605
+ }
1606
+
1607
+ fn move_end(&mut self) {
1608
+ self.cursor = self.full.len();
1609
+ }
1610
+
1400
1611
  /// Ctrl+U / Cmd+Delete — erase the entire line.
1401
1612
  fn clear_all(&mut self) {
1402
1613
  self.full.clear();
1403
1614
  self.display.clear();
1404
1615
  self.segments.clear();
1616
+ self.cursor = 0;
1405
1617
  }
1406
1618
 
1407
- /// Ctrl+W / Option+Delete — erase the last word (whitespace-delimited).
1619
+ /// Ctrl+W / Option+Delete — erase the last word before the cursor.
1408
1620
  fn pop_word(&mut self) {
1409
- // Trim trailing whitespace first, then remove up to the previous whitespace boundary.
1410
- while self.full.ends_with(' ') {
1411
- self.pop_last();
1621
+ while self.cursor > 0 && self.full[..self.cursor].ends_with(' ') {
1622
+ self.delete_before_cursor();
1412
1623
  }
1413
- while !self.full.is_empty() && !self.full.ends_with(' ') {
1414
- self.pop_last();
1624
+ while self.cursor > 0 && !self.full[..self.cursor].ends_with(' ') {
1625
+ self.delete_before_cursor();
1415
1626
  }
1416
1627
  }
1628
+
1629
+ fn rebuild_flat(&mut self) {
1630
+ self.full = self.segments.iter().map(|s| s.full.as_str()).collect();
1631
+ self.display = self.segments.iter().map(|s| s.display.as_str()).collect();
1632
+ }
1417
1633
  }
1418
1634
 
1419
1635
  #[cfg(unix)]
@@ -1473,11 +1689,23 @@ impl RawPromptMode {
1473
1689
  }
1474
1690
  }
1475
1691
 
1692
+ /// After a redraw (which leaves the terminal cursor at end of display), move it
1693
+ /// back to the buffer's logical cursor position.
1694
+ fn position_prompt_cursor(display: &str, cursor_char: usize) -> io::Result<()> {
1695
+ let back = display.chars().count().saturating_sub(cursor_char);
1696
+ if back > 0 {
1697
+ print!("\x1b[{}D", back);
1698
+ io::stdout().flush()?;
1699
+ }
1700
+ Ok(())
1701
+ }
1702
+
1476
1703
  fn read_prompt_line(
1477
1704
  label: &str,
1478
1705
  width: usize,
1479
1706
  paste_count: &mut usize,
1480
1707
  images_available: bool,
1708
+ input_history: &[String],
1481
1709
  ) -> Result<PromptRead> {
1482
1710
  let _raw_mode = RawPromptMode::enter()?;
1483
1711
  let mut input = io::stdin().lock();
@@ -1485,6 +1713,10 @@ fn read_prompt_line(
1485
1713
  let mut display_rows = 1usize;
1486
1714
  let mut ctrl_v_image: Option<ImageAttachment> = None;
1487
1715
 
1716
+ // History navigation state.
1717
+ let mut hist_idx: Option<usize> = None; // None = current live input
1718
+ let mut saved_input = String::new(); // stash live input when navigating into history
1719
+
1488
1720
  // Compose the visible prompt label, optionally prefixed with an image indicator.
1489
1721
  let effective_label = |img: &Option<ImageAttachment>| -> String {
1490
1722
  if img.is_some() {
@@ -1494,6 +1726,16 @@ fn read_prompt_line(
1494
1726
  }
1495
1727
  };
1496
1728
 
1729
+ // Redraw the line and position the cursor, returning the new row count.
1730
+ macro_rules! redraw {
1731
+ () => {{
1732
+ let lbl = effective_label(&ctrl_v_image);
1733
+ let rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1734
+ let _ = position_prompt_cursor(&buffer.display, buffer.display_cursor_char());
1735
+ rows
1736
+ }};
1737
+ }
1738
+
1497
1739
  print!("{}", effective_label(&ctrl_v_image));
1498
1740
  io::stdout().flush().context("failed to write prompt")?;
1499
1741
 
@@ -1509,35 +1751,28 @@ fn read_prompt_line(
1509
1751
  return Ok(PromptRead::Line(buffer.full, ctrl_v_image));
1510
1752
  }
1511
1753
  3 => {
1512
- // Ctrl+C — discard any pasted image too
1513
- ctrl_v_image = None;
1514
1754
  println!("^C");
1515
1755
  return Ok(PromptRead::Interrupted);
1516
1756
  }
1517
1757
  4 if buffer.is_empty() => return Ok(PromptRead::Eof),
1518
1758
  8 | 127 => {
1519
1759
  // Backspace
1520
- buffer.pop_last();
1521
- let lbl = effective_label(&ctrl_v_image);
1522
- display_rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1760
+ buffer.delete_before_cursor();
1761
+ display_rows = redraw!();
1523
1762
  }
1524
1763
  21 => {
1525
1764
  // Ctrl+U / Cmd+Delete — erase entire line
1526
1765
  buffer.clear_all();
1527
- let lbl = effective_label(&ctrl_v_image);
1528
- display_rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1766
+ display_rows = redraw!();
1529
1767
  }
1530
1768
  22 if images_available => {
1531
1769
  // Ctrl+V — paste image from clipboard
1532
1770
  match grab_clipboard_image() {
1533
1771
  Some(img) => {
1534
1772
  ctrl_v_image = Some(img);
1535
- let lbl = effective_label(&ctrl_v_image);
1536
- display_rows =
1537
- redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1773
+ display_rows = redraw!();
1538
1774
  }
1539
1775
  None => {
1540
- // No image in clipboard — ring the bell
1541
1776
  print!("\x07");
1542
1777
  let _ = io::stdout().flush();
1543
1778
  }
@@ -1546,23 +1781,90 @@ fn read_prompt_line(
1546
1781
  23 => {
1547
1782
  // Ctrl+W / Option+Delete — erase last word
1548
1783
  buffer.pop_word();
1549
- let lbl = effective_label(&ctrl_v_image);
1550
- display_rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1784
+ display_rows = redraw!();
1551
1785
  }
1552
1786
  0x1b => {
1553
1787
  let sequence = read_escape_sequence(&mut input)?;
1554
- if sequence == b"[200~" {
1555
- let paste = normalize_pasted_text(read_bracketed_paste(&mut input)?);
1556
- push_paste(&mut buffer, paste, paste_count);
1557
- let lbl = effective_label(&ctrl_v_image);
1558
- display_rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1788
+ match sequence.as_slice() {
1789
+ b"[200~" => {
1790
+ // Bracketed paste
1791
+ let paste = normalize_pasted_text(read_bracketed_paste(&mut input)?);
1792
+ push_paste(&mut buffer, paste, paste_count);
1793
+ display_rows = redraw!();
1794
+ }
1795
+ b"[A" => {
1796
+ // Up arrow — previous history entry
1797
+ if input_history.is_empty() {
1798
+ continue;
1799
+ }
1800
+ let new_idx = match hist_idx {
1801
+ None => {
1802
+ saved_input = buffer.full.clone();
1803
+ input_history.len() - 1
1804
+ }
1805
+ Some(0) => 0,
1806
+ Some(i) => i - 1,
1807
+ };
1808
+ hist_idx = Some(new_idx);
1809
+ buffer = PromptBuffer::default();
1810
+ buffer.push_text(&input_history[new_idx].clone());
1811
+ display_rows = redraw!();
1812
+ }
1813
+ b"[B" => {
1814
+ // Down arrow — next history entry / back to live input
1815
+ match hist_idx {
1816
+ None => {}
1817
+ Some(i) if i + 1 >= input_history.len() => {
1818
+ hist_idx = None;
1819
+ let text = std::mem::take(&mut saved_input);
1820
+ buffer = PromptBuffer::default();
1821
+ buffer.push_text(&text);
1822
+ display_rows = redraw!();
1823
+ }
1824
+ Some(i) => {
1825
+ hist_idx = Some(i + 1);
1826
+ buffer = PromptBuffer::default();
1827
+ buffer.push_text(&input_history[i + 1].clone());
1828
+ display_rows = redraw!();
1829
+ }
1830
+ }
1831
+ }
1832
+ b"[C" => {
1833
+ // Right arrow
1834
+ buffer.move_right();
1835
+ let _ = position_prompt_cursor(
1836
+ &buffer.display,
1837
+ buffer.display_cursor_char(),
1838
+ );
1839
+ }
1840
+ b"[D" => {
1841
+ // Left arrow
1842
+ buffer.move_left();
1843
+ let _ = position_prompt_cursor(
1844
+ &buffer.display,
1845
+ buffer.display_cursor_char(),
1846
+ );
1847
+ }
1848
+ b"[H" | b"[1~" => {
1849
+ // Home
1850
+ buffer.move_home();
1851
+ let _ = position_prompt_cursor(&buffer.display, 0);
1852
+ }
1853
+ b"[F" | b"[4~" => {
1854
+ // End
1855
+ buffer.move_end();
1856
+ let _ = position_prompt_cursor(
1857
+ &buffer.display,
1858
+ buffer.display_cursor_char(),
1859
+ );
1860
+ }
1861
+ _ => {}
1559
1862
  }
1560
1863
  }
1561
1864
  byte if byte >= 0x20 && byte != 0x7f => {
1562
1865
  if let Some(ch) = read_utf8_char(byte, &mut input)? {
1563
1866
  buffer.push_text(ch.encode_utf8(&mut [0; 4]));
1564
- let lbl = effective_label(&ctrl_v_image);
1565
- display_rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1867
+ display_rows = redraw!();
1566
1868
  }
1567
1869
  }
1568
1870
  _ => {}
@@ -1820,7 +2122,7 @@ fn image_mime_for_path(path: &Path) -> Option<&'static str> {
1820
2122
  /// Try to grab an image from the system clipboard and return it base64-encoded.
1821
2123
  /// Only supported on macOS; returns None on other platforms or when no image is present.
1822
2124
  #[cfg(target_os = "macos")]
1823
- fn grab_clipboard_image() -> Option<ImageAttachment> {
2125
+ pub fn grab_clipboard_image() -> Option<ImageAttachment> {
1824
2126
  read_clipboard_image().ok()
1825
2127
  }
1826
2128
 
@@ -1996,7 +2298,7 @@ fn convert_tiff_to_png(tiff: &[u8]) -> Result<Vec<u8>> {
1996
2298
  }
1997
2299
 
1998
2300
  #[cfg(not(target_os = "macos"))]
1999
- fn grab_clipboard_image() -> Option<ImageAttachment> {
2301
+ pub fn grab_clipboard_image() -> Option<ImageAttachment> {
2000
2302
  None
2001
2303
  }
2002
2304
 
@@ -2012,7 +2314,7 @@ fn repl_history_path() -> Option<PathBuf> {
2012
2314
  Some(dir.join("history"))
2013
2315
  }
2014
2316
 
2015
- fn unix_now() -> u64 {
2317
+ pub fn unix_now() -> u64 {
2016
2318
  std::time::SystemTime::now()
2017
2319
  .duration_since(std::time::UNIX_EPOCH)
2018
2320
  .unwrap_or_default()
@@ -2109,6 +2411,16 @@ fn load_interactive_session(path: &Path, cwd: &Path) -> Option<InteractiveSessio
2109
2411
  Some(session)
2110
2412
  }
2111
2413
 
2414
+ pub fn save_interactive_session_pub(
2415
+ path: &Path,
2416
+ cwd: &Path,
2417
+ provider: &str,
2418
+ options: &AskOptions,
2419
+ history: &[ChatMessage],
2420
+ ) -> Result<()> {
2421
+ save_interactive_session(path, cwd, provider, options, history)
2422
+ }
2423
+
2112
2424
  fn save_interactive_session(
2113
2425
  path: &Path,
2114
2426
  cwd: &Path,