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/Cargo.lock +352 -5
- package/Cargo.toml +3 -1
- package/package.json +1 -1
- package/src/lib.rs +380 -68
- package/src/tui.rs +1122 -0
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,
|
|
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
|
|
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!("
|
|
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!("
|
|
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
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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
|
|
1619
|
+
/// Ctrl+W / Option+Delete — erase the last word before the cursor.
|
|
1408
1620
|
fn pop_word(&mut self) {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
self.pop_last();
|
|
1621
|
+
while self.cursor > 0 && self.full[..self.cursor].ends_with(' ') {
|
|
1622
|
+
self.delete_before_cursor();
|
|
1412
1623
|
}
|
|
1413
|
-
while
|
|
1414
|
-
self.
|
|
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.
|
|
1521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
-
|
|
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,
|