vtcode 0.169.3

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
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(),
        }
    }
}

pub(crate) struct InlineQueueState<'a> {
    handle: &'a InlineHandle,
    queued_inputs: &'a mut VecDeque<QueuedInput>,
    prefer_latest_once: &'a mut 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 }
    }

    pub(crate) fn push(&mut self, input: SubmittedInput, primary_agent: Option<String>) {
        self.queued_inputs.push_back(QueuedInput::new(input, primary_agent));
        self.sync_handle_queue();
    }

    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.sync_handle_queue();
        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.sync_handle_queue();
            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.sync_handle_queue();
        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.sync_handle_queue();
        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.sync_handle_queue();
    }

    /// 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.sync_handle_queue();
        self.queued_inputs.len()
    }

    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());
    }
}