vtcode 0.170.0

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
use std::collections::VecDeque;

use vtcode_ui::tui::app::{InlineHandle, SubmittedInput};

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct QueuedInput {
    pub(crate) input: SubmittedInput,
    pub(crate) primary_agent: Option<String>,
    /// Only Ctrl+Enter submissions are batchable; plain-Enter slash commands
    /// queued while busy must dispatch as their own turn.
    pub(crate) batchable: bool,
}

impl QueuedInput {
    pub(crate) fn new(input: SubmittedInput, primary_agent: Option<String>) -> Self {
        Self {
            batchable: input.batchable,
            input,
            primary_agent: primary_agent.filter(|name| !name.trim().is_empty()),
        }
    }

    pub(crate) fn display_label(&self) -> String {
        match self.primary_agent.as_deref() {
            Some(agent) => format!("{agent}: {}", self.input.text),
            None => self.input.text.clone(),
        }
    }
}

/// Soft cap on concurrently queued user inputs. Under a paste-storm the
/// oldest entry is dropped once the cap is exceeded so the authoritative
/// VecDeque cannot grow without bound; the newest submissions are kept.
pub(crate) const MAX_QUEUED_INPUTS: usize = 256;

pub(crate) struct InlineQueueState<'a> {
    handle: &'a InlineHandle,
    queued_inputs: &'a mut VecDeque<QueuedInput>,
    prefer_latest_once: &'a mut bool,
    /// When true, the TUI overlay is stale relative to `queued_inputs` and
    /// needs `flush_sync`. Deferred so a drain of N QueueSubmit events does
    /// not publish N partial snapshots that make optimistic UI entries
    /// flicker out of existence between acknowledgements.
    sync_dirty: bool,
}

impl<'a> InlineQueueState<'a> {
    pub(crate) fn new(
        handle: &'a InlineHandle,
        queued_inputs: &'a mut VecDeque<QueuedInput>,
        prefer_latest_once: &'a mut bool,
    ) -> Self {
        Self {
            handle,
            queued_inputs,
            prefer_latest_once,
            sync_dirty: false,
        }
    }

    pub(crate) fn push(&mut self, input: SubmittedInput, primary_agent: Option<String>) {
        self.queued_inputs.push_back(QueuedInput::new(input, primary_agent));
        // Enforce a soft FIFO cap: drop the oldest only after the newest has
        // been accepted so rapid influx cannot grow the queue without bound.
        while self.queued_inputs.len() > MAX_QUEUED_INPUTS {
            self.queued_inputs.pop_front();
        }
        self.mark_sync_dirty();
    }

    pub(crate) fn take_next_submission(&mut self) -> Option<QueuedInput> {
        let result = if *self.prefer_latest_once {
            *self.prefer_latest_once = false;
            self.queued_inputs.pop_back()
        } else {
            self.queued_inputs.pop_front()
        };
        self.mark_sync_dirty();
        result
    }

    /// Pop the next submission plus every following batchable text-only
    /// submission for the same agent, joined into ONE combined prompt so
    /// several queued Ctrl+Enter messages reach the model in a single turn
    /// instead of one turn each. Non-batchable items (plain Enter, attachments,
    /// agent changes) stop the batch and dispatch alone on their own turns.
    pub(crate) fn take_batched_submission(&mut self) -> Option<QueuedInput> {
        // A Ctrl+Enter "run the latest now" promotion must stay a single-turn
        // dispatch: merging the newest item with older FIFO items would batch
        // turns the user asked to run individually.
        let promoted_latest = *self.prefer_latest_once;
        let mut batch = self.take_next_submission()?;
        // Only batchable (Ctrl+Enter) submissions coalesce, and never a
        // submission carrying attachments — the association between an image
        // and its message must stay intact. A plain Enter that reached the
        // front must also dispatch alone and never absorb following items.
        if !batch.batchable || promoted_latest || batch.input.has_attachments() {
            tracing::debug!(
                target: "vtcode_ui::queue",
                batched_count = 1,
                text_bytes = batch.input.text.len(),
                "queue submission drained (single, non-batchable)"
            );
            self.mark_sync_dirty();
            return Some(batch);
        }
        let primary_agent = batch.primary_agent.clone();
        let mut batched_count = 1usize;
        const MAX_BATCH_ITEMS: usize = 32;
        // Cap the combined prompt so a user hammering Ctrl+Enter hundreds of
        // times cannot build one unbounded message.
        const MAX_BATCH_BYTES: usize = 64 * 1024;
        while batched_count < MAX_BATCH_ITEMS {
            let Some(next) = self.queued_inputs.front() else {
                break;
            };
            if !next.batchable || next.input.has_attachments() || next.primary_agent != primary_agent {
                break;
            }
            // Cap the COMBINED prompt: merging `next` must not exceed the
            // budget, otherwise one large queued message could overshoot it.
            let needs_separator = !batch.input.text.trim().is_empty() && !next.input.text.trim().is_empty();
            let merged_len = batch.input.text.len() + usize::from(needs_separator) * 2 + next.input.text.len();
            if merged_len > MAX_BATCH_BYTES {
                break;
            }
            let Some(next) = self.queued_inputs.pop_front() else {
                break;
            };
            batched_count += 1;
            if needs_separator {
                batch.input.text.push_str("\n\n");
            }
            batch.input.text.push_str(&next.input.text);
        }
        tracing::debug!(
            target: "vtcode_ui::queue",
            batched_count,
            text_bytes = batch.input.text.len(),
            "queue submission drained"
        );
        self.mark_sync_dirty();
        Some(batch)
    }

    pub(crate) fn prefer_latest_next(&mut self) {
        *self.prefer_latest_once = !self.queued_inputs.is_empty();
    }

    pub(crate) fn edit_latest(&mut self) -> Option<String> {
        let result = self.queued_inputs.pop_back().map(|queued| queued.input.text);
        if result.is_some() {
            *self.prefer_latest_once = false;
        }
        self.mark_sync_dirty();
        result
    }

    #[allow(
        dead_code,
        reason = "Explicit clear-all path for future queue UI; interrupts preserve by design."
    )]
    pub(crate) fn clear(&mut self) {
        self.queued_inputs.clear();
        *self.prefer_latest_once = false;
        self.mark_sync_dirty();
    }

    /// Preserve queued inputs across an interrupt: reset any one-shot
    /// latest-promotion without dropping user-queued work. Returns the
    /// preserved count so the interrupt notice can report it instead of
    /// silently clearing the queue.
    pub(crate) fn preserve_on_interrupt(&mut self) -> usize {
        *self.prefer_latest_once = false;
        self.mark_sync_dirty();
        self.queued_inputs.len()
    }

    /// Publish the authoritative FIFO to the TUI overlay if it is stale.
    ///
    /// Call this once after a drain batch (and before dispatching a queued
    /// submission) so the overlay sees one consistent snapshot instead of
    /// N partial ones that race against optimistic UI entries.
    pub(crate) fn flush_sync(&mut self) {
        if !self.sync_dirty {
            return;
        }
        self.sync_dirty = false;
        self.sync_handle_queue();
    }

    fn mark_sync_dirty(&mut self) {
        self.sync_dirty = true;
    }

    fn sync_handle_queue(&self) {
        self.handle
            .set_queued_inputs(self.queued_inputs.iter().map(QueuedInput::display_label).collect());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pushes_drain_in_fifo_order() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.push("first".into(), Some("duck".to_string()));
        queue.push("second".into(), Some("build".to_string()));
        queue.push("third".into(), Some("review".to_string()));

        // The queue is strict FIFO: first queued runs first.
        assert_eq!(queue.take_next_submission().map(|queued| queued.input.text).as_deref(), Some("first"));
        assert_eq!(queue.take_next_submission().map(|queued| queued.input.text).as_deref(), Some("second"));
        assert_eq!(queue.take_next_submission().map(|queued| queued.input.text).as_deref(), Some("third"));
    }

    #[test]
    fn prefer_latest_next_promotes_existing_queue_without_reordering_it() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::from([
            QueuedInput::new("first".into(), Some("duck".to_string())),
            QueuedInput::new("second".into(), Some("build".to_string())),
            QueuedInput::new("third".into(), Some("review".to_string())),
        ]);
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.prefer_latest_next();

        assert_eq!(queue.take_next_submission().map(|queued| queued.input.text).as_deref(), Some("third"));
        assert_eq!(queue.take_next_submission().map(|queued| queued.input.text).as_deref(), Some("first"));
        assert_eq!(queue.take_next_submission().map(|queued| queued.input.text).as_deref(), Some("second"));
    }

    #[test]
    fn take_batched_submission_joins_consecutive_batchable_items() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        for text in ["first", "second", "third"] {
            queue.push(SubmittedInput::new(text, Vec::new()).batchable(), None);
        }

        // The queue is strict FIFO: queued order is preserved inside the batch.
        let batched = queue.take_batched_submission().expect("batched submission");
        assert_eq!(batched.input.text, "first\n\nsecond\n\nthird");
        assert!(queue.take_batched_submission().is_none());
    }

    #[test]
    fn take_batched_submission_stops_at_plain_enter_attachments_or_agent_change() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.push("text one".into(), Some("planner".to_string()));
        queue.push(
            SubmittedInput::new("with image", vec![vtcode_ui::tui::app::ContentPart::image("img", "image/png")]),
            None,
        );
        queue.push(SubmittedInput::new("text two", Vec::new()).batchable(), None);

        // Strict FIFO: the planner-tagged plain-Enter item runs first alone,
        // then the attachment item alone (it stops batching), then the
        // batchable item alone because the queue behind it is already empty.
        let planner_item = queue.take_batched_submission().expect("planner submission");
        assert_eq!(planner_item.input.text, "text one");

        let with_image = queue.take_batched_submission().expect("attachment submission");
        assert_eq!(with_image.input.text, "with image");

        let batched = queue.take_batched_submission().expect("last submission");
        assert_eq!(batched.input.text, "text two");

        assert!(queue.take_batched_submission().is_none());
    }

    #[test]
    fn take_batched_submission_runs_plain_enter_items_as_their_own_turns() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.push(SubmittedInput::new("batch me", Vec::new()).batchable(), None);
        queue.push("plain enter one".into(), None);
        queue.push("plain enter two".into(), None);

        // Strict FIFO: the batchable item runs alone because the plain-Enter
        // item behind it stops the batch, then each plain-Enter item runs as
        // its own turn.
        let first = queue.take_batched_submission().expect("first submission");
        assert_eq!(first.input.text, "batch me");

        let second = queue.take_batched_submission().expect("second submission");
        assert_eq!(second.input.text, "plain enter one");

        let third = queue.take_batched_submission().expect("third submission");
        assert_eq!(third.input.text, "plain enter two");

        assert!(queue.take_batched_submission().is_none());
    }

    #[test]
    fn take_batched_submission_keeps_attachment_head_single() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.push(
            SubmittedInput::new("see image", vec![vtcode_ui::tui::app::ContentPart::image("img", "image/png")]),
            None,
        );
        queue.push(SubmittedInput::new("follow-up", Vec::new()).batchable(), None);

        // The attachment-carrying head must dispatch alone so the image stays
        // associated with its message; the following batchable item may then
        // run alone (nothing else behind it).
        let first = queue.take_batched_submission().expect("attachment head");
        assert_eq!(first.input.text, "see image");
        assert!(first.input.has_attachments());

        let second = queue.take_batched_submission().expect("follow-up");
        assert_eq!(second.input.text, "follow-up");
        assert!(queue.take_batched_submission().is_none());
    }

    #[test]
    fn take_batched_submission_does_not_merge_after_prefer_latest_promotion() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.push(SubmittedInput::new("first", Vec::new()).batchable(), None);
        queue.push(SubmittedInput::new("second", Vec::new()).batchable(), None);
        queue.push(SubmittedInput::new("third", Vec::new()).batchable(), None);

        // Ctrl+Enter with empty input while idle promotes the newest item to
        // run alone NOW; it must not absorb the older items into one batch.
        queue.prefer_latest_next();
        let promoted = queue.take_batched_submission().expect("promoted submission");
        assert_eq!(promoted.input.text, "third");

        // Remaining queue drains in strict FIFO inside one batch.
        let batch = queue.take_batched_submission().expect("remaining batch");
        assert_eq!(batch.input.text, "first\n\nsecond");
        assert!(queue.take_batched_submission().is_none());
    }

    #[test]
    fn take_batched_submission_skips_separator_for_whitespace_items() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.push(SubmittedInput::new("first", Vec::new()).batchable(), None);
        queue.push(SubmittedInput::new("   ", Vec::new()).batchable(), None);
        queue.push(SubmittedInput::new("third", Vec::new()).batchable(), None);

        let batch = queue.take_batched_submission().expect("batch");
        // The whitespace-only item is still its own message: no separator is
        // injected for it, but it must remain distinct from "third".
        assert_eq!(batch.input.text, "first   \n\nthird");
    }

    #[test]
    fn take_batched_submission_caps_batch_size() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        // 100 batchable items must not become one unbounded prompt.
        for i in 0..100 {
            queue.push(SubmittedInput::new(format!("msg {i}"), Vec::new()).batchable(), None);
        }

        let batch = queue.take_batched_submission().expect("batch");
        let item_count = batch.input.text.split("\n\n").count();
        assert!(item_count <= 32, "batch must be capped, got {item_count} items");
        assert!(queue.take_batched_submission().is_some(), "leftover items must still drain");
    }

    #[test]
    fn queued_input_keeps_primary_agent_captured_at_queue_time() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.push("first".into(), Some("planner".to_string()));
        queue.push("second".into(), Some("builder".to_string()));

        let first = queue.take_next_submission().expect("first queued input");
        assert_eq!(first.input.text, "first");
        assert_eq!(first.primary_agent.as_deref(), Some("planner"));

        let latest = queue.take_next_submission().expect("second queued input");
        assert_eq!(latest.input.text, "second");
        assert_eq!(latest.primary_agent.as_deref(), Some("builder"));
    }

    #[test]
    fn queued_input_preserves_attachments_in_order() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        let first = vtcode_ui::tui::app::ContentPart::image("first", "image/png");
        let second = vtcode_ui::tui::app::ContentPart::image("second", "image/jpeg");
        queue.push(SubmittedInput::new("see images", vec![first.clone(), second.clone()]), None);

        let queued = queue.take_next_submission().expect("queued input");
        assert_eq!(queued.input.text, "see images");
        assert_eq!(queued.input.attachments, vec![first, second]);
    }

    #[test]
    fn preserve_on_interrupt_keeps_fifo_and_resets_promotion() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        queue.push("first".into(), None);
        queue.push("second".into(), None);
        queue.prefer_latest_next();

        let preserved = queue.preserve_on_interrupt();
        assert_eq!(preserved, 2);

        // Promotion reset: oldest dispatches first, not the newest.
        // FIFO order intact after interrupt preservation.
        assert_eq!(queue.take_next_submission().map(|q| q.input.text).as_deref(), Some("first"));
        assert_eq!(queue.take_next_submission().map(|q| q.input.text).as_deref(), Some("second"));
        assert!(queue.take_next_submission().is_none());
    }

    #[test]
    fn flush_sync_publishes_one_snapshot_after_drain() {
        use vtcode_ui::tui::app::InlineCommand;

        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;
        let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);

        // Rapid influx: three pushes, no intermediate overlay publishes.
        queue.push("first".into(), None);
        queue.push("second".into(), None);
        queue.push("third".into(), None);
        assert!(rx.try_recv().is_err(), "push must not publish partial overlay snapshots");

        queue.flush_sync();
        match rx.try_recv() {
            Ok(InlineCommand::SetQueuedInputs { entries }) => {
                assert_eq!(entries, vec!["first".to_string(), "second".to_string(), "third".to_string()]);
            }
            Ok(_) => panic!("expected SetQueuedInputs snapshot"),
            Err(err) => panic!("expected one SetQueuedInputs snapshot, got {err:?}"),
        }
        assert!(rx.try_recv().is_err(), "flush must publish exactly one snapshot");
    }

    #[test]
    fn push_caps_fifo_and_keeps_newest() {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let handle = InlineHandle::new_for_tests(tx);
        let mut queued_inputs = VecDeque::new();
        let mut prefer_latest_once = false;

        {
            let mut queue = InlineQueueState::new(&handle, &mut queued_inputs, &mut prefer_latest_once);
            for index in 0..(MAX_QUEUED_INPUTS + 10) {
                queue.push(format!("msg {index}").into(), None);
            }
        }
        assert_eq!(queued_inputs.len(), MAX_QUEUED_INPUTS);
        assert_eq!(queued_inputs.front().map(|q| q.input.text.as_str()), Some("msg 10"));
        assert_eq!(
            queued_inputs.back().map(|q| q.input.text.as_str()),
            Some(format!("msg {}", MAX_QUEUED_INPUTS + 9).as_str())
        );
    }
}