anveesa 0.3.5 → 0.3.6

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
@@ -54,7 +54,7 @@ dependencies = [
54
54
 
55
55
  [[package]]
56
56
  name = "anveesa"
57
- version = "0.3.5"
57
+ version = "0.3.6"
58
58
  dependencies = [
59
59
  "anyhow",
60
60
  "base64",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "anveesa"
3
- version = "0.3.5"
3
+ version = "0.3.6"
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.3.5",
3
+ "version": "0.3.6",
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/lib.rs CHANGED
@@ -24,8 +24,8 @@ use crate::{
24
24
  set_default_provider,
25
25
  },
26
26
  provider::{
27
- ApprovalDecision, ApprovalPolicy, ChatMessage, DiffKind, ImageAttachment, PromptRequest,
28
- StreamEvent, ToolConfirmPreview, TurnResult, Usage,
27
+ ApprovalDecision, ApprovalPolicy, ChatMessage, ChatRole, DiffKind, ImageAttachment,
28
+ PromptRequest, StreamEvent, ToolConfirmPreview, TurnResult, Usage,
29
29
  },
30
30
  };
31
31
 
@@ -123,7 +123,24 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
123
123
  let session_saved_at = loaded_session.as_ref().filter(|s| s.saved_at > 0).map(|s| s.saved_at);
124
124
  // tracks the most recent successful save this run — kept fresh for /session display
125
125
  let mut last_saved_at: u64 = session_saved_at.unwrap_or(0);
126
+ // Per-project system prompt: load .anveesa from cwd if no --system was given.
127
+ if session_options.system.is_none() {
128
+ if let Ok(text) = fs::read_to_string(cwd.join(".anveesa")) {
129
+ let trimmed = text.trim().to_string();
130
+ if !trimmed.is_empty() {
131
+ session_options.system = Some(trimmed);
132
+ }
133
+ }
134
+ }
135
+
126
136
  let history_path = repl_history_path();
137
+ // Load prompt history for ↑/↓ recall (one entry per line, newest at end).
138
+ let input_history: Vec<String> = history_path
139
+ .as_deref()
140
+ .and_then(|p| fs::read_to_string(p).ok())
141
+ .map(|c| c.lines().filter(|l| !l.is_empty()).map(String::from).collect())
142
+ .unwrap_or_default();
143
+
127
144
  print_session_header(
128
145
  &provider_name,
129
146
  session_options.model.as_deref().unwrap_or("-"),
@@ -144,7 +161,7 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
144
161
  loop {
145
162
  print_input_separator(is_tty, width);
146
163
  let (line, ctrl_v_image) =
147
- match read_prompt_line(&label, width, &mut paste_count, images_available) {
164
+ match read_prompt_line(&label, width, &mut paste_count, images_available, &input_history) {
148
165
  Ok(PromptRead::Line(line, img)) => (line, img),
149
166
  Ok(PromptRead::Interrupted) => continue,
150
167
  Ok(PromptRead::Eof) => {
@@ -197,6 +214,25 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
197
214
  );
198
215
  continue;
199
216
  }
217
+ s if s.starts_with("/export") => {
218
+ let arg = s.strip_prefix("/export").unwrap().trim();
219
+ let path = if arg.is_empty() {
220
+ cwd.join(format!("anveesa-export-{}.md", unix_now()))
221
+ } else {
222
+ std::path::PathBuf::from(arg)
223
+ };
224
+ match export_conversation(&path, &history) {
225
+ Ok(()) => {
226
+ if is_tty {
227
+ eprintln!("\x1b[2m Exported to {}\x1b[0m", path.display());
228
+ } else {
229
+ println!("exported to {}", path.display());
230
+ }
231
+ }
232
+ Err(e) => eprintln!("\x1b[1;31m✗\x1b[0m {e:#}"),
233
+ }
234
+ continue;
235
+ }
200
236
  "/status" => {
201
237
  print_status_inline(
202
238
  is_tty,
@@ -1107,8 +1143,9 @@ fn print_status_inline(
1107
1143
 
1108
1144
  fn print_help_inline(is_tty: bool) {
1109
1145
  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");
1146
+ println!("commands: /clear, /export [path], /session, /attach [path], /exit, /quit, /help");
1147
+ println!("keys: ↑/↓ history ←/→ cursor Home/End Ctrl+W delete-word Ctrl+U clear-line");
1148
+ println!("images: Ctrl+V to paste clipboard image, or copy then send to auto-attach");
1112
1149
  return;
1113
1150
  }
1114
1151
  println!();
@@ -1116,6 +1153,7 @@ fn print_help_inline(is_tty: bool) {
1116
1153
  println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1117
1154
  println!(" \x1b[1;32m/status\x1b[0m provider, model, turns, token usage");
1118
1155
  println!(" \x1b[1;32m/session\x1b[0m show session file, age, and turn count");
1156
+ println!(" \x1b[1;32m/export\x1b[0m \x1b[2m[path]\x1b[0m save conversation to a markdown file");
1119
1157
  println!(" \x1b[1;32m/model\x1b[0m \x1b[2m[name]\x1b[0m switch or show current model");
1120
1158
  println!(" \x1b[1;32m/provider\x1b[0m \x1b[2m[name]\x1b[0m switch or show current provider");
1121
1159
  println!(" \x1b[1;32m/clear\x1b[0m reset conversation and delete saved session");
@@ -1123,9 +1161,19 @@ fn print_help_inline(is_tty: bool) {
1123
1161
  println!(" \x1b[1;32m/exit\x1b[0m, \x1b[1;32m/quit\x1b[0m leave the session");
1124
1162
  println!(" \x1b[1;32m/help\x1b[0m show this message");
1125
1163
  println!();
1164
+ println!("\x1b[2m Keyboard\x1b[0m");
1165
+ println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1166
+ println!(" \x1b[2m↑ / ↓\x1b[0m recall previous / next prompt");
1167
+ println!(" \x1b[2m← / →\x1b[0m move cursor left / right");
1168
+ println!(" \x1b[2mHome / End\x1b[0m jump to start / end of line");
1169
+ println!(" \x1b[2mCtrl+W\x1b[0m delete word before cursor");
1170
+ println!(" \x1b[2mCtrl+U\x1b[0m clear entire line \x1b[2m(also Cmd+Delete)\x1b[0m");
1171
+ println!(" \x1b[2mCtrl+V\x1b[0m paste image from clipboard");
1172
+ println!();
1126
1173
  println!("\x1b[2m Images\x1b[0m");
1127
1174
  println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1128
- println!(" Cmd+C an image, then send a message it attaches automatically.");
1175
+ println!(" \x1b[2mCtrl+V\x1b[0m to paste a clipboard image inline (shows \x1b[2m[📎]\x1b[0m indicator).");
1176
+ println!(" Or Cmd+C an image and send any message — it attaches automatically.");
1129
1177
  println!(" Or use \x1b[1;32m/attach\x1b[0m \x1b[2mpath/to/file.png\x1b[0m for a specific file.");
1130
1178
  println!(" For broadest clipboard support: \x1b[2mbrew install pngpaste\x1b[0m");
1131
1179
  println!();
@@ -1167,6 +1215,26 @@ fn print_session_info(is_tty: bool, path: Option<&Path>, turns: usize, saved_at:
1167
1215
  println!();
1168
1216
  }
1169
1217
 
1218
+ fn export_conversation(path: &std::path::Path, history: &[ChatMessage]) -> Result<()> {
1219
+ let mut out = String::new();
1220
+ for msg in history {
1221
+ match msg.role {
1222
+ ChatRole::User => {
1223
+ out.push_str("## You\n\n");
1224
+ out.push_str(&msg.content);
1225
+ out.push_str("\n\n");
1226
+ }
1227
+ ChatRole::Assistant => {
1228
+ out.push_str("## Assistant\n\n");
1229
+ out.push_str(&msg.content);
1230
+ out.push_str("\n\n");
1231
+ }
1232
+ }
1233
+ }
1234
+ fs::write(path, out.trim_end())
1235
+ .with_context(|| format!("failed to write {}", path.display()))
1236
+ }
1237
+
1170
1238
  fn list_providers() -> Result<()> {
1171
1239
  let config = AppConfig::load()?;
1172
1240
  let is_tty = io::stdout().is_terminal();
@@ -1336,6 +1404,8 @@ struct PromptBuffer {
1336
1404
  full: String,
1337
1405
  display: String,
1338
1406
  segments: Vec<PromptSegment>,
1407
+ /// Byte offset into `full` — where the next insertion goes.
1408
+ cursor: usize,
1339
1409
  }
1340
1410
 
1341
1411
  impl PromptBuffer {
@@ -1343,28 +1413,63 @@ impl PromptBuffer {
1343
1413
  self.full.is_empty()
1344
1414
  }
1345
1415
 
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;
1416
+ /// Char offset in `display` that corresponds to the current cursor position in `full`.
1417
+ /// Used to position the terminal cursor after a redraw.
1418
+ fn display_cursor_char(&self) -> usize {
1419
+ let mut full_pos = 0usize;
1420
+ let mut disp_chars = 0usize;
1421
+ for seg in &self.segments {
1422
+ let seg_len = seg.full.len();
1423
+ let next_pos = full_pos + seg_len;
1424
+ if self.cursor <= next_pos {
1425
+ let offset = self.cursor - full_pos;
1426
+ return if seg.hidden {
1427
+ // Hidden spans are atomic: cursor snaps to end of placeholder.
1428
+ disp_chars + seg.display.chars().count()
1429
+ } else {
1430
+ disp_chars + seg.full[..offset].chars().count()
1431
+ };
1432
+ }
1433
+ full_pos = next_pos;
1434
+ disp_chars += seg.display.chars().count();
1356
1435
  }
1436
+ disp_chars
1437
+ }
1357
1438
 
1358
- self.segments.push(PromptSegment {
1359
- full: text.to_string(),
1360
- display: text.to_string(),
1361
- hidden: false,
1362
- });
1439
+ fn push_text(&mut self, text: &str) {
1440
+ // Find the segment containing the cursor and insert there.
1441
+ let mut pos = 0usize;
1442
+ for seg in self.segments.iter_mut() {
1443
+ let seg_len = seg.full.len();
1444
+ if !seg.hidden && self.cursor >= pos && self.cursor <= pos + seg_len {
1445
+ let offset = self.cursor - pos;
1446
+ seg.full.insert_str(offset, text);
1447
+ seg.display.insert_str(offset, text);
1448
+ self.cursor += text.len();
1449
+ self.rebuild_flat();
1450
+ return;
1451
+ }
1452
+ pos += seg_len;
1453
+ }
1454
+ // Cursor is at end or after a hidden segment — append to last visible segment.
1455
+ if let Some(seg) = self.segments.last_mut().filter(|s| !s.hidden) {
1456
+ seg.full.push_str(text);
1457
+ seg.display.push_str(text);
1458
+ } else {
1459
+ self.segments.push(PromptSegment {
1460
+ full: text.to_string(),
1461
+ display: text.to_string(),
1462
+ hidden: false,
1463
+ });
1464
+ }
1465
+ self.cursor += text.len();
1466
+ self.rebuild_flat();
1363
1467
  }
1364
1468
 
1365
1469
  fn push_hidden_paste(&mut self, text: String, display: String) {
1366
1470
  self.full.push_str(&text);
1367
1471
  self.display.push_str(&display);
1472
+ self.cursor = self.full.len();
1368
1473
  self.segments.push(PromptSegment {
1369
1474
  full: text,
1370
1475
  display,
@@ -1372,48 +1477,118 @@ impl PromptBuffer {
1372
1477
  });
1373
1478
  }
1374
1479
 
1375
- fn pop_last(&mut self) {
1376
- let Some(segment) = self.segments.last_mut() else {
1480
+ /// Delete the character immediately before the cursor.
1481
+ /// Deletes the entire span atomically if the cursor is just past a hidden span.
1482
+ fn delete_before_cursor(&mut self) {
1483
+ if self.cursor == 0 {
1377
1484
  return;
1378
- };
1485
+ }
1486
+ let mut pos = 0usize;
1487
+ let mut remove_idx: Option<usize> = None;
1488
+ for (i, seg) in self.segments.iter_mut().enumerate() {
1489
+ let seg_len = seg.full.len();
1490
+ let next_pos = pos + seg_len;
1491
+ if seg.hidden && next_pos == self.cursor {
1492
+ // cursor is right after a hidden span — delete the whole span
1493
+ self.cursor -= seg_len;
1494
+ remove_idx = Some(i);
1495
+ break;
1496
+ }
1497
+ if !seg.hidden && self.cursor > pos && self.cursor <= next_pos {
1498
+ let offset = self.cursor - pos;
1499
+ if let Some(ch) = seg.full[..offset].chars().next_back() {
1500
+ let ch_len = ch.len_utf8();
1501
+ seg.full.drain((offset - ch_len)..offset);
1502
+ seg.display.drain((offset - ch_len)..offset);
1503
+ self.cursor -= ch_len;
1504
+ if seg.full.is_empty() {
1505
+ remove_idx = Some(i);
1506
+ }
1507
+ }
1508
+ break;
1509
+ }
1510
+ pos = next_pos;
1511
+ }
1512
+ if let Some(i) = remove_idx {
1513
+ self.segments.remove(i);
1514
+ }
1515
+ self.rebuild_flat();
1516
+ }
1379
1517
 
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();
1518
+ fn move_left(&mut self) {
1519
+ if self.cursor == 0 {
1387
1520
  return;
1388
1521
  }
1522
+ let mut pos = 0usize;
1523
+ for seg in &self.segments {
1524
+ let next_pos = pos + seg.full.len();
1525
+ if seg.hidden && next_pos == self.cursor {
1526
+ self.cursor = pos;
1527
+ return;
1528
+ }
1529
+ if !seg.hidden && self.cursor > pos && self.cursor <= next_pos {
1530
+ let offset = self.cursor - pos;
1531
+ if let Some(ch) = seg.full[..offset].chars().next_back() {
1532
+ self.cursor -= ch.len_utf8();
1533
+ }
1534
+ return;
1535
+ }
1536
+ pos = next_pos;
1537
+ }
1538
+ }
1389
1539
 
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();
1540
+ fn move_right(&mut self) {
1541
+ if self.cursor >= self.full.len() {
1542
+ return;
1543
+ }
1544
+ let mut pos = 0usize;
1545
+ for seg in &self.segments {
1546
+ let seg_len = seg.full.len();
1547
+ if seg.hidden && pos == self.cursor {
1548
+ self.cursor += seg_len;
1549
+ return;
1550
+ }
1551
+ if !seg.hidden && self.cursor >= pos && self.cursor < pos + seg_len {
1552
+ let offset = self.cursor - pos;
1553
+ if let Some(ch) = seg.full[offset..].chars().next() {
1554
+ self.cursor += ch.len_utf8();
1555
+ }
1556
+ return;
1557
+ }
1558
+ pos += seg_len;
1397
1559
  }
1398
1560
  }
1399
1561
 
1562
+ fn move_home(&mut self) {
1563
+ self.cursor = 0;
1564
+ }
1565
+
1566
+ fn move_end(&mut self) {
1567
+ self.cursor = self.full.len();
1568
+ }
1569
+
1400
1570
  /// Ctrl+U / Cmd+Delete — erase the entire line.
1401
1571
  fn clear_all(&mut self) {
1402
1572
  self.full.clear();
1403
1573
  self.display.clear();
1404
1574
  self.segments.clear();
1575
+ self.cursor = 0;
1405
1576
  }
1406
1577
 
1407
- /// Ctrl+W / Option+Delete — erase the last word (whitespace-delimited).
1578
+ /// Ctrl+W / Option+Delete — erase the last word before the cursor.
1408
1579
  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();
1580
+ while self.cursor > 0 && self.full[..self.cursor].ends_with(' ') {
1581
+ self.delete_before_cursor();
1412
1582
  }
1413
- while !self.full.is_empty() && !self.full.ends_with(' ') {
1414
- self.pop_last();
1583
+ while self.cursor > 0 && !self.full[..self.cursor].ends_with(' ') {
1584
+ self.delete_before_cursor();
1415
1585
  }
1416
1586
  }
1587
+
1588
+ fn rebuild_flat(&mut self) {
1589
+ self.full = self.segments.iter().map(|s| s.full.as_str()).collect();
1590
+ self.display = self.segments.iter().map(|s| s.display.as_str()).collect();
1591
+ }
1417
1592
  }
1418
1593
 
1419
1594
  #[cfg(unix)]
@@ -1473,11 +1648,23 @@ impl RawPromptMode {
1473
1648
  }
1474
1649
  }
1475
1650
 
1651
+ /// After a redraw (which leaves the terminal cursor at end of display), move it
1652
+ /// back to the buffer's logical cursor position.
1653
+ fn position_prompt_cursor(display: &str, cursor_char: usize) -> io::Result<()> {
1654
+ let back = display.chars().count().saturating_sub(cursor_char);
1655
+ if back > 0 {
1656
+ print!("\x1b[{}D", back);
1657
+ io::stdout().flush()?;
1658
+ }
1659
+ Ok(())
1660
+ }
1661
+
1476
1662
  fn read_prompt_line(
1477
1663
  label: &str,
1478
1664
  width: usize,
1479
1665
  paste_count: &mut usize,
1480
1666
  images_available: bool,
1667
+ input_history: &[String],
1481
1668
  ) -> Result<PromptRead> {
1482
1669
  let _raw_mode = RawPromptMode::enter()?;
1483
1670
  let mut input = io::stdin().lock();
@@ -1485,6 +1672,10 @@ fn read_prompt_line(
1485
1672
  let mut display_rows = 1usize;
1486
1673
  let mut ctrl_v_image: Option<ImageAttachment> = None;
1487
1674
 
1675
+ // History navigation state.
1676
+ let mut hist_idx: Option<usize> = None; // None = current live input
1677
+ let mut saved_input = String::new(); // stash live input when navigating into history
1678
+
1488
1679
  // Compose the visible prompt label, optionally prefixed with an image indicator.
1489
1680
  let effective_label = |img: &Option<ImageAttachment>| -> String {
1490
1681
  if img.is_some() {
@@ -1494,6 +1685,16 @@ fn read_prompt_line(
1494
1685
  }
1495
1686
  };
1496
1687
 
1688
+ // Redraw the line and position the cursor, returning the new row count.
1689
+ macro_rules! redraw {
1690
+ () => {{
1691
+ let lbl = effective_label(&ctrl_v_image);
1692
+ let rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1693
+ let _ = position_prompt_cursor(&buffer.display, buffer.display_cursor_char());
1694
+ rows
1695
+ }};
1696
+ }
1697
+
1497
1698
  print!("{}", effective_label(&ctrl_v_image));
1498
1699
  io::stdout().flush().context("failed to write prompt")?;
1499
1700
 
@@ -1509,35 +1710,28 @@ fn read_prompt_line(
1509
1710
  return Ok(PromptRead::Line(buffer.full, ctrl_v_image));
1510
1711
  }
1511
1712
  3 => {
1512
- // Ctrl+C — discard any pasted image too
1513
- ctrl_v_image = None;
1514
1713
  println!("^C");
1515
1714
  return Ok(PromptRead::Interrupted);
1516
1715
  }
1517
1716
  4 if buffer.is_empty() => return Ok(PromptRead::Eof),
1518
1717
  8 | 127 => {
1519
1718
  // 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)?;
1719
+ buffer.delete_before_cursor();
1720
+ display_rows = redraw!();
1523
1721
  }
1524
1722
  21 => {
1525
1723
  // Ctrl+U / Cmd+Delete — erase entire line
1526
1724
  buffer.clear_all();
1527
- let lbl = effective_label(&ctrl_v_image);
1528
- display_rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1725
+ display_rows = redraw!();
1529
1726
  }
1530
1727
  22 if images_available => {
1531
1728
  // Ctrl+V — paste image from clipboard
1532
1729
  match grab_clipboard_image() {
1533
1730
  Some(img) => {
1534
1731
  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)?;
1732
+ display_rows = redraw!();
1538
1733
  }
1539
1734
  None => {
1540
- // No image in clipboard — ring the bell
1541
1735
  print!("\x07");
1542
1736
  let _ = io::stdout().flush();
1543
1737
  }
@@ -1546,23 +1740,90 @@ fn read_prompt_line(
1546
1740
  23 => {
1547
1741
  // Ctrl+W / Option+Delete — erase last word
1548
1742
  buffer.pop_word();
1549
- let lbl = effective_label(&ctrl_v_image);
1550
- display_rows = redraw_prompt_line(&lbl, &buffer.display, display_rows, width)?;
1743
+ display_rows = redraw!();
1551
1744
  }
1552
1745
  0x1b => {
1553
1746
  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)?;
1747
+ match sequence.as_slice() {
1748
+ b"[200~" => {
1749
+ // Bracketed paste
1750
+ let paste = normalize_pasted_text(read_bracketed_paste(&mut input)?);
1751
+ push_paste(&mut buffer, paste, paste_count);
1752
+ display_rows = redraw!();
1753
+ }
1754
+ b"[A" => {
1755
+ // Up arrow — previous history entry
1756
+ if input_history.is_empty() {
1757
+ continue;
1758
+ }
1759
+ let new_idx = match hist_idx {
1760
+ None => {
1761
+ saved_input = buffer.full.clone();
1762
+ input_history.len() - 1
1763
+ }
1764
+ Some(0) => 0,
1765
+ Some(i) => i - 1,
1766
+ };
1767
+ hist_idx = Some(new_idx);
1768
+ buffer = PromptBuffer::default();
1769
+ buffer.push_text(&input_history[new_idx].clone());
1770
+ display_rows = redraw!();
1771
+ }
1772
+ b"[B" => {
1773
+ // Down arrow — next history entry / back to live input
1774
+ match hist_idx {
1775
+ None => {}
1776
+ Some(i) if i + 1 >= input_history.len() => {
1777
+ hist_idx = None;
1778
+ let text = std::mem::take(&mut saved_input);
1779
+ buffer = PromptBuffer::default();
1780
+ buffer.push_text(&text);
1781
+ display_rows = redraw!();
1782
+ }
1783
+ Some(i) => {
1784
+ hist_idx = Some(i + 1);
1785
+ buffer = PromptBuffer::default();
1786
+ buffer.push_text(&input_history[i + 1].clone());
1787
+ display_rows = redraw!();
1788
+ }
1789
+ }
1790
+ }
1791
+ b"[C" => {
1792
+ // Right arrow
1793
+ buffer.move_right();
1794
+ let _ = position_prompt_cursor(
1795
+ &buffer.display,
1796
+ buffer.display_cursor_char(),
1797
+ );
1798
+ }
1799
+ b"[D" => {
1800
+ // Left arrow
1801
+ buffer.move_left();
1802
+ let _ = position_prompt_cursor(
1803
+ &buffer.display,
1804
+ buffer.display_cursor_char(),
1805
+ );
1806
+ }
1807
+ b"[H" | b"[1~" => {
1808
+ // Home
1809
+ buffer.move_home();
1810
+ let _ = position_prompt_cursor(&buffer.display, 0);
1811
+ }
1812
+ b"[F" | b"[4~" => {
1813
+ // End
1814
+ buffer.move_end();
1815
+ let _ = position_prompt_cursor(
1816
+ &buffer.display,
1817
+ buffer.display_cursor_char(),
1818
+ );
1819
+ }
1820
+ _ => {}
1559
1821
  }
1560
1822
  }
1561
1823
  byte if byte >= 0x20 && byte != 0x7f => {
1562
1824
  if let Some(ch) = read_utf8_char(byte, &mut input)? {
1563
1825
  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)?;
1826
+ display_rows = redraw!();
1566
1827
  }
1567
1828
  }
1568
1829
  _ => {}