anveesa 0.3.4 → 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.4"
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.4"
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.4",
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("-"),
@@ -143,15 +160,22 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
143
160
 
144
161
  loop {
145
162
  print_input_separator(is_tty, width);
146
- let line = match read_prompt_line(&label, width, &mut paste_count) {
147
- Ok(PromptRead::Line(line)) => line,
148
- Ok(PromptRead::Interrupted) => continue,
149
- Ok(PromptRead::Eof) => {
150
- println!();
151
- break;
152
- }
153
- Err(error) => return Err(error).context("failed to read interactive prompt"),
154
- };
163
+ let (line, ctrl_v_image) =
164
+ match read_prompt_line(&label, width, &mut paste_count, images_available, &input_history) {
165
+ Ok(PromptRead::Line(line, img)) => (line, img),
166
+ Ok(PromptRead::Interrupted) => continue,
167
+ Ok(PromptRead::Eof) => {
168
+ println!();
169
+ break;
170
+ }
171
+ Err(error) => return Err(error).context("failed to read interactive prompt"),
172
+ };
173
+
174
+ // Ctrl+V image takes precedence over a previously pending image.
175
+ if let Some(img) = ctrl_v_image {
176
+ last_image_fp = Some(image_fingerprint(&img));
177
+ pending_image = Some(img);
178
+ }
155
179
 
156
180
  let prompt = line.trim().to_string();
157
181
  if prompt.is_empty() {
@@ -190,6 +214,25 @@ async fn run_interactive(options: AskOptions) -> Result<()> {
190
214
  );
191
215
  continue;
192
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
+ }
193
236
  "/status" => {
194
237
  print_status_inline(
195
238
  is_tty,
@@ -1100,8 +1143,9 @@ fn print_status_inline(
1100
1143
 
1101
1144
  fn print_help_inline(is_tty: bool) {
1102
1145
  if !is_tty {
1103
- println!("commands: /clear, /session, /attach [path], /exit, /quit, /help");
1104
- 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");
1105
1149
  return;
1106
1150
  }
1107
1151
  println!();
@@ -1109,6 +1153,7 @@ fn print_help_inline(is_tty: bool) {
1109
1153
  println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1110
1154
  println!(" \x1b[1;32m/status\x1b[0m provider, model, turns, token usage");
1111
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");
1112
1157
  println!(" \x1b[1;32m/model\x1b[0m \x1b[2m[name]\x1b[0m switch or show current model");
1113
1158
  println!(" \x1b[1;32m/provider\x1b[0m \x1b[2m[name]\x1b[0m switch or show current provider");
1114
1159
  println!(" \x1b[1;32m/clear\x1b[0m reset conversation and delete saved session");
@@ -1116,9 +1161,19 @@ fn print_help_inline(is_tty: bool) {
1116
1161
  println!(" \x1b[1;32m/exit\x1b[0m, \x1b[1;32m/quit\x1b[0m leave the session");
1117
1162
  println!(" \x1b[1;32m/help\x1b[0m show this message");
1118
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!();
1119
1173
  println!("\x1b[2m Images\x1b[0m");
1120
1174
  println!("\x1b[90m ──────────────────────────────────────\x1b[0m");
1121
- 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.");
1122
1177
  println!(" Or use \x1b[1;32m/attach\x1b[0m \x1b[2mpath/to/file.png\x1b[0m for a specific file.");
1123
1178
  println!(" For broadest clipboard support: \x1b[2mbrew install pngpaste\x1b[0m");
1124
1179
  println!();
@@ -1160,6 +1215,26 @@ fn print_session_info(is_tty: bool, path: Option<&Path>, turns: usize, saved_at:
1160
1215
  println!();
1161
1216
  }
1162
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
+
1163
1238
  fn list_providers() -> Result<()> {
1164
1239
  let config = AppConfig::load()?;
1165
1240
  let is_tty = io::stdout().is_terminal();
@@ -1313,7 +1388,7 @@ fn print_session_header(
1313
1388
  }
1314
1389
 
1315
1390
  enum PromptRead {
1316
- Line(String),
1391
+ Line(String, Option<ImageAttachment>),
1317
1392
  Interrupted,
1318
1393
  Eof,
1319
1394
  }
@@ -1329,6 +1404,8 @@ struct PromptBuffer {
1329
1404
  full: String,
1330
1405
  display: String,
1331
1406
  segments: Vec<PromptSegment>,
1407
+ /// Byte offset into `full` — where the next insertion goes.
1408
+ cursor: usize,
1332
1409
  }
1333
1410
 
1334
1411
  impl PromptBuffer {
@@ -1336,28 +1413,63 @@ impl PromptBuffer {
1336
1413
  self.full.is_empty()
1337
1414
  }
1338
1415
 
1339
- fn push_text(&mut self, text: &str) {
1340
- self.full.push_str(text);
1341
- self.display.push_str(text);
1342
-
1343
- if let Some(segment) = self.segments.last_mut()
1344
- && !segment.hidden
1345
- {
1346
- segment.full.push_str(text);
1347
- segment.display.push_str(text);
1348
- 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();
1349
1435
  }
1436
+ disp_chars
1437
+ }
1350
1438
 
1351
- self.segments.push(PromptSegment {
1352
- full: text.to_string(),
1353
- display: text.to_string(),
1354
- hidden: false,
1355
- });
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();
1356
1467
  }
1357
1468
 
1358
1469
  fn push_hidden_paste(&mut self, text: String, display: String) {
1359
1470
  self.full.push_str(&text);
1360
1471
  self.display.push_str(&display);
1472
+ self.cursor = self.full.len();
1361
1473
  self.segments.push(PromptSegment {
1362
1474
  full: text,
1363
1475
  display,
@@ -1365,29 +1477,94 @@ impl PromptBuffer {
1365
1477
  });
1366
1478
  }
1367
1479
 
1368
- fn pop_last(&mut self) {
1369
- 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 {
1370
1484
  return;
1371
- };
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
+ }
1372
1517
 
1373
- if segment.hidden {
1374
- let full_len = segment.full.len();
1375
- let display_len = segment.display.len();
1376
- self.full.truncate(self.full.len().saturating_sub(full_len));
1377
- self.display
1378
- .truncate(self.display.len().saturating_sub(display_len));
1379
- self.segments.pop();
1518
+ fn move_left(&mut self) {
1519
+ if self.cursor == 0 {
1380
1520
  return;
1381
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
+ }
1382
1539
 
1383
- let _ = segment.full.pop();
1384
- let _ = segment.display.pop();
1385
- let _ = self.full.pop();
1386
- let _ = self.display.pop();
1387
-
1388
- if segment.full.is_empty() {
1389
- self.segments.pop();
1540
+ fn move_right(&mut self) {
1541
+ if self.cursor >= self.full.len() {
1542
+ return;
1390
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;
1559
+ }
1560
+ }
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();
1391
1568
  }
1392
1569
 
1393
1570
  /// Ctrl+U / Cmd+Delete — erase the entire line.
@@ -1395,18 +1572,23 @@ impl PromptBuffer {
1395
1572
  self.full.clear();
1396
1573
  self.display.clear();
1397
1574
  self.segments.clear();
1575
+ self.cursor = 0;
1398
1576
  }
1399
1577
 
1400
- /// Ctrl+W / Option+Delete — erase the last word (whitespace-delimited).
1578
+ /// Ctrl+W / Option+Delete — erase the last word before the cursor.
1401
1579
  fn pop_word(&mut self) {
1402
- // Trim trailing whitespace first, then remove up to the previous whitespace boundary.
1403
- while self.full.ends_with(' ') {
1404
- self.pop_last();
1580
+ while self.cursor > 0 && self.full[..self.cursor].ends_with(' ') {
1581
+ self.delete_before_cursor();
1405
1582
  }
1406
- while !self.full.is_empty() && !self.full.ends_with(' ') {
1407
- self.pop_last();
1583
+ while self.cursor > 0 && !self.full[..self.cursor].ends_with(' ') {
1584
+ self.delete_before_cursor();
1408
1585
  }
1409
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
+ }
1410
1592
  }
1411
1593
 
1412
1594
  #[cfg(unix)]
@@ -1466,13 +1648,54 @@ impl RawPromptMode {
1466
1648
  }
1467
1649
  }
1468
1650
 
1469
- fn read_prompt_line(label: &str, width: usize, paste_count: &mut usize) -> Result<PromptRead> {
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
+
1662
+ fn read_prompt_line(
1663
+ label: &str,
1664
+ width: usize,
1665
+ paste_count: &mut usize,
1666
+ images_available: bool,
1667
+ input_history: &[String],
1668
+ ) -> Result<PromptRead> {
1470
1669
  let _raw_mode = RawPromptMode::enter()?;
1471
1670
  let mut input = io::stdin().lock();
1472
1671
  let mut buffer = PromptBuffer::default();
1473
1672
  let mut display_rows = 1usize;
1673
+ let mut ctrl_v_image: Option<ImageAttachment> = None;
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
+
1679
+ // Compose the visible prompt label, optionally prefixed with an image indicator.
1680
+ let effective_label = |img: &Option<ImageAttachment>| -> String {
1681
+ if img.is_some() {
1682
+ format!("\x1b[2m[📎]\x1b[0m {label}")
1683
+ } else {
1684
+ label.to_string()
1685
+ }
1686
+ };
1474
1687
 
1475
- print!("{label}");
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
+
1698
+ print!("{}", effective_label(&ctrl_v_image));
1476
1699
  io::stdout().flush().context("failed to write prompt")?;
1477
1700
 
1478
1701
  loop {
@@ -1484,7 +1707,7 @@ fn read_prompt_line(label: &str, width: usize, paste_count: &mut usize) -> Resul
1484
1707
  match byte[0] {
1485
1708
  b'\r' | b'\n' => {
1486
1709
  println!();
1487
- return Ok(PromptRead::Line(buffer.full));
1710
+ return Ok(PromptRead::Line(buffer.full, ctrl_v_image));
1488
1711
  }
1489
1712
  3 => {
1490
1713
  println!("^C");
@@ -1493,31 +1716,114 @@ fn read_prompt_line(label: &str, width: usize, paste_count: &mut usize) -> Resul
1493
1716
  4 if buffer.is_empty() => return Ok(PromptRead::Eof),
1494
1717
  8 | 127 => {
1495
1718
  // Backspace
1496
- buffer.pop_last();
1497
- display_rows = redraw_prompt_line(label, &buffer.display, display_rows, width)?;
1719
+ buffer.delete_before_cursor();
1720
+ display_rows = redraw!();
1498
1721
  }
1499
1722
  21 => {
1500
1723
  // Ctrl+U / Cmd+Delete — erase entire line
1501
1724
  buffer.clear_all();
1502
- display_rows = redraw_prompt_line(label, &buffer.display, display_rows, width)?;
1725
+ display_rows = redraw!();
1726
+ }
1727
+ 22 if images_available => {
1728
+ // Ctrl+V — paste image from clipboard
1729
+ match grab_clipboard_image() {
1730
+ Some(img) => {
1731
+ ctrl_v_image = Some(img);
1732
+ display_rows = redraw!();
1733
+ }
1734
+ None => {
1735
+ print!("\x07");
1736
+ let _ = io::stdout().flush();
1737
+ }
1738
+ }
1503
1739
  }
1504
1740
  23 => {
1505
1741
  // Ctrl+W / Option+Delete — erase last word
1506
1742
  buffer.pop_word();
1507
- display_rows = redraw_prompt_line(label, &buffer.display, display_rows, width)?;
1743
+ display_rows = redraw!();
1508
1744
  }
1509
1745
  0x1b => {
1510
1746
  let sequence = read_escape_sequence(&mut input)?;
1511
- if sequence == b"[200~" {
1512
- let paste = normalize_pasted_text(read_bracketed_paste(&mut input)?);
1513
- push_paste(&mut buffer, paste, paste_count);
1514
- display_rows = redraw_prompt_line(label, &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
+ _ => {}
1515
1821
  }
1516
1822
  }
1517
1823
  byte if byte >= 0x20 && byte != 0x7f => {
1518
1824
  if let Some(ch) = read_utf8_char(byte, &mut input)? {
1519
1825
  buffer.push_text(ch.encode_utf8(&mut [0; 4]));
1520
- display_rows = redraw_prompt_line(label, &buffer.display, display_rows, width)?;
1826
+ display_rows = redraw!();
1521
1827
  }
1522
1828
  }
1523
1829
  _ => {}