tmux-mcp 0.1.0-alpha.16

Model Context Protocol server exposing tmux through libtmux (alpha)
Documentation
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
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
//! Scenario tests for the echo contract: `wait_for_text` versus the pane's
//! echo of text this MCP server itself typed with `send_keys`.
//!
//! Scenarios S1-S6 below are named to match the contract they were drafted
//! against. Every "matches real output" assertion pairs a floor on elapsed
//! time with the outcome: a match that lands before the pane's own `sleep 1`
//! can finish is a match on the echo, not on what the command produced.

#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]

use std::time::{Duration, Instant};

use libtmux::test::TestServer;
use tokio_util::sync::CancellationToken;

mod support;

use support::{args, bare_tools, json, prompt_ready};

/// S1: a short, unsubmitted answer must never mask later real output that
/// contains it as a substring, and must never mask a whole row.
///
/// `y` is typed and left pending (no Enter) while a command already
/// submitted earlier is still running in the background; when it finishes,
/// its output ends in the same letter the pending answer is. A wait for
/// `ready` must still see it.
#[tokio::test]
async fn s1_a_pending_answer_never_hides_real_output_containing_it() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "s1"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();
    prompt_ready(guard.server(), &pane).await;

    // The job's one-second sleep starts when this send is dispatched, so the
    // clock starts before it. Started after the next send instead, a loaded
    // machine spends part of that second typing `y`, and the real output
    // arrives inside the bound that is meant to exclude it.
    let started = Instant::now();

    // A real, independent producer of "ready" -- backgrounded so the prompt
    // returns immediately and the pending keystroke below lands on a fresh
    // line, not mid-dispatch of this one.
    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "text": "(sleep 1; echo ready) &",
            "enter": true
        })))
        .await
        .expect("the background job starts");

    // A short pending answer that is also the last letter of the word this
    // waits for: proof against masking by "contains this substring"
    // instead of by the submitted line it actually is.
    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "text": "y",
            "enter": false
        })))
        .await
        .expect("the pending keystroke is typed");

    let view = json(
        tools
            .wait_for_text(
                args(serde_json::json!({
                    "pane": pane,
                    "patterns": ["ready"],
                    "seconds": 5
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );

    assert_eq!(view["outcome"], "matched", "{view}");
    assert!(
        started.elapsed() >= Duration::from_millis(800),
        "matched after only {:?}, too fast to be the background job's real output",
        started.elapsed()
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

/// S2 (wait started after the send): a line this server typed and submitted
/// is not the match; only the command's own output is.
#[tokio::test]
async fn s2_after_a_submitted_commands_echo_is_not_the_match() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "s2-after"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();
    prompt_ready(guard.server(), &pane).await;

    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "text": "sleep 1; echo MARKER",
            "enter": true
        })))
        .await
        .expect("the command is sent and this call returns only once it is");

    let started = Instant::now();
    let view = json(
        tools
            .wait_for_text(
                args(serde_json::json!({
                    "pane": pane,
                    "patterns": ["MARKER"],
                    "seconds": 5
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );

    assert_eq!(
        view["outcome"], "matched",
        "must not time out over a masked echo either: {view}"
    );
    assert!(
        started.elapsed() >= Duration::from_millis(800),
        "matched after only {:?}, too fast to be sleep 1's real output -- the echo, not the \
         output, was matched",
        started.elapsed()
    );
    assert!(
        view["text"]
            .as_str()
            .unwrap_or_default()
            .lines()
            .any(|line| line.trim() == "MARKER"),
        "the bare output line must be in the reported text: {view}"
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

/// S3: text typed but never submitted times out (reported here as `pending`,
/// this port's outcome for exactly this case) rather than matching, and does
/// so promptly rather than waiting out the deadline.
#[tokio::test]
async fn s3_unsubmitted_text_is_pending_not_matched() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "s3"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();
    prompt_ready(guard.server(), &pane).await;

    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "text": "echo MARKER",
            "enter": false
        })))
        .await
        .expect("the line is typed but not submitted");

    let started = Instant::now();
    let view = json(
        tools
            .wait_for_text(
                args(serde_json::json!({
                    "pane": pane,
                    "patterns": ["MARKER"],
                    "seconds": 1
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );

    assert_eq!(view["outcome"], "pending", "{view}");
    assert!(
        started.elapsed() < Duration::from_millis(500),
        "an unsubmitted pattern must not wait out the deadline: {:?}",
        started.elapsed()
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

/// S4: edits made before a line is submitted are applied, and none of the
/// key names used to make them -- `BSpace` here -- becomes part of the
/// submitted text. The precise claim that `BSpace` never becomes literal
/// text is checked at the unit level
/// (`crate::echo::tests::backspaces_reach_an_earlier_calls_pending_text`);
/// this is the end-to-end shape of the same workflow.
#[tokio::test]
async fn s4_edits_are_applied_before_a_line_is_submitted() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "s4"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();
    prompt_ready(guard.server(), &pane).await;

    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "text": "xMARKER",
            "enter": false
        })))
        .await
        .expect("the typo is typed");
    let backspaces: Vec<&str> = vec!["BSpace"; 7];
    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "keys": backspaces
        })))
        .await
        .expect("the typo is erased in a separate call");
    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "text": "echo MARKER",
            "enter": true
        })))
        .await
        .expect("the corrected line is submitted");

    let view = json(
        tools
            .wait_for_text(
                args(serde_json::json!({
                    "pane": pane,
                    "patterns": ["MARKER"],
                    "seconds": 5
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );

    // No `sleep` in this scenario, so the correction, submission, and its
    // output can all land before `wait_for_text` even attaches; either
    // outcome below means the mask found the real row, not the echo.
    assert!(
        matches!(
            view["outcome"].as_str(),
            Some("matched" | "present_at_entry")
        ),
        "{view}"
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

/// S5: unsubmitted type-ahead into a pane whose shell has not drawn its
/// first prompt yet must never be reported as a match. No `prompt_ready`
/// wait here -- a cold shell is the point of this scenario.
#[tokio::test]
async fn s5_unsubmitted_type_ahead_into_a_cold_shell_is_never_matched() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    // A shell that reads nothing for a moment: this server's own type-ahead
    // can arrive on the live stream looking exactly like fresh output once
    // the pane's process starts reading it.
    guard
        .server()
        .cmd(
            libtmux::Command::new("set-option")
                .arg("-g")
                .arg("default-command")
                .arg("stty raw -echo; sleep 0.4; exec cat"),
        )
        .await
        .expect("the cold-shell fixture command is set");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "s5"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();

    // The pane's program changes under the send as the fixture's `sleep`
    // gives way to `cat`, and the server refuses a send that races that
    // change before dispatching anything. That refusal is its job, so a
    // refused send is made again; once `cat` runs it echoes the text back,
    // and that echo must be discounted just the same.
    let queued = libtmux::test::retry_until(Duration::from_secs(5), async || {
        tools
            .send_keys(args(serde_json::json!({
                "pane": pane,
                "text": "MARKER",
                "enter": false
            })))
            .await
            .is_ok()
    })
    .await;
    assert!(queued.is_ok(), "type-ahead is queued");

    let view = json(
        tools
            .wait_for_text(
                args(serde_json::json!({
                    "pane": pane,
                    "patterns": ["MARKER"],
                    "seconds": 2
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );

    assert_ne!(
        view["outcome"], "matched",
        "unsubmitted type-ahead must never read as a match: {view}"
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

/// S6: after a key this server cannot apply to its tracked text (an arrow
/// key here), it stops discounting that pane's current line rather than
/// keep masking text an edit it could not represent may have moved past.
/// bash runs the pane so `Left` has its ordinary readline meaning -- pure
/// cursor movement, no visible effect on the line -- rather than the
/// undefined one plain `/bin/sh` (this fixture's default) gives it.
#[tokio::test]
async fn s6_an_unrecognized_key_stops_discounting_the_line() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    guard
        .server()
        .cmd(
            libtmux::Command::new("set-option")
                .arg("-g")
                .arg("default-command")
                .arg("/bin/bash --noprofile --norc"),
        )
        .await
        .expect("bash is set as the pane's command");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "s6"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();
    prompt_ready(guard.server(), &pane).await;

    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "text": "MARKER",
            "enter": false
        })))
        .await
        .expect("the line is typed");
    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "keys": ["Left"]
        })))
        .await
        .expect("an unmodelable key is sent");
    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "keys": ["Enter"]
        })))
        .await
        .expect("the line is submitted");

    let view = json(
        tools
            .wait_for_text(
                args(serde_json::json!({
                    "pane": pane,
                    "patterns": ["MARKER"],
                    "seconds": 3
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );

    // No `sleep` here either -- bash rejects the bare command fast enough
    // that this can resolve as `present_at_entry` as easily as `matched`.
    // The discriminator is the outcome family, not which of the two it is:
    // a stale mask would remove `MARKER` from both the echo and the error
    // line, since both are exactly that word, and time out instead.
    assert!(
        matches!(
            view["outcome"].as_str(),
            Some("matched" | "present_at_entry")
        ),
        "an unrecognized key must stop discounting the line rather than keep hiding a real \
         error message that happens to repeat it: {view}"
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

/// Resize constraint: a window resize between a command being submitted and
/// its output arriving must not defeat the mask. This mask is keyed to the
/// submitted line's own text, not to a remembered row, so a resize is not
/// expected to change the outcome here -- this is the no-resize control
/// (`s2_after_a_submitted_commands_echo_is_not_the_match`) run again with a
/// resize between submit and wait, to check that stays true.
#[tokio::test]
async fn a_resize_between_submit_and_output_does_not_defeat_the_mask() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "resize"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();
    prompt_ready(guard.server(), &pane).await;

    tools
        .send_keys(args(serde_json::json!({
            "pane": pane,
            "text": "sleep 1; echo MARKER",
            "enter": true
        })))
        .await
        .expect("the command is sent");

    // Reflow the pane's rows before the real output arrives.
    guard
        .server()
        .cmd(
            libtmux::Command::new("resize-window")
                .arg("-t")
                .arg(&pane)
                .arg("-x")
                .arg("50")
                .arg("-y")
                .arg("20"),
        )
        .await
        .expect("the window resizes");

    let started = Instant::now();
    let view = json(
        tools
            .wait_for_text(
                args(serde_json::json!({
                    "pane": pane,
                    "patterns": ["MARKER"],
                    "seconds": 5
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );

    assert_eq!(view["outcome"], "matched", "{view}");
    assert!(
        started.elapsed() >= Duration::from_millis(800),
        "matched after only {:?}, too fast to be real output",
        started.elapsed()
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

/// S2 via `paste_text`: a line this server pastes and submits with Enter is
/// not the match; only the command's own output is.
#[tokio::test]
async fn s2_pasted_text_with_enter_is_not_the_match() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "s2-paste"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();
    prompt_ready(guard.server(), &pane).await;

    tools
        .paste_text(args(serde_json::json!({
            "pane": pane,
            "text": "sleep 1; echo MARKER",
            "enter": true
        })))
        .await
        .expect("the command is pasted and this call returns only once it is");

    let started = Instant::now();
    let view = json(
        tools
            .wait_for_text(
                args(serde_json::json!({
                    "pane": pane,
                    "patterns": ["MARKER"],
                    "seconds": 5
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );

    assert_eq!(
        view["outcome"], "matched",
        "must not time out over a masked echo either: {view}"
    );
    assert!(
        started.elapsed() >= Duration::from_millis(800),
        "matched after only {:?}, too fast to be sleep 1's real output -- the paste's own echo, \
         not the output, was matched",
        started.elapsed()
    );
    assert!(
        view["text"]
            .as_str()
            .unwrap_or_default()
            .lines()
            .any(|line| line.trim() == "MARKER"),
        "the bare output line must be in the reported text: {view}"
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}

/// S2 via `run_shell_command`: the short line it types to load and run its
/// staged command frame must not read as a later wait's match; the
/// command's real output still does.
///
/// The window is widened before dispatch: the staged-frame line's length
/// depends on its random nonce and can approach the default 80-column
/// width, and a wrapped line is past what the whole-line mask can find --
/// the gap `wait_for_text` documents.
#[tokio::test]
async fn s2_run_shell_commands_dispatch_line_is_not_the_match() {
    let guard = TestServer::builder().start().await.expect("tmux starts");
    let tools = bare_tools(guard.server());
    tools
        .create_session(args(serde_json::json!({"name": "s2-run"})))
        .await
        .expect("session starts");
    let pane = json(tools.list_panes().await.expect("panes"))["panes"][0]["id"]
        .as_str()
        .expect("pane id")
        .to_owned();
    prompt_ready(guard.server(), &pane).await;
    guard
        .server()
        .cmd(
            libtmux::Command::new("resize-window")
                .arg("-t")
                .arg(&pane)
                .arg("-x")
                .arg("120")
                .arg("-y")
                .arg("24"),
        )
        .await
        .expect("the window widens past the staged frame line's length");

    let finished = json(
        tools
            .run_command(
                args(serde_json::json!({
                    "pane": pane,
                    "command": "printf done",
                    "seconds": 5
                })),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("the command runs"),
    );
    assert_eq!(finished["outcome"], "completed", "{finished}");

    // "eval" names no output of "printf done"; its only appearance on the
    // pane is the line run_shell_command itself typed to load and run the
    // staged frame. A wait for it must not read that echo as a match.
    let echo = json(
        tools
            .wait_for_text(
                args(serde_json::json!({"pane": pane, "patterns": ["eval"], "seconds": 1})),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );
    assert!(
        !matches!(
            echo["outcome"].as_str(),
            Some("matched" | "present_at_entry")
        ),
        "run_shell_command's own dispatch line must not read as a match: {echo}"
    );

    // The command's real output is unaffected by masking the dispatch line.
    let output = json(
        tools
            .wait_for_text(
                args(serde_json::json!({"pane": pane, "patterns": ["done"], "seconds": 1})),
                CancellationToken::new(),
                tmux_mcp::Reporter::none(),
            )
            .await
            .expect("wait completes"),
    );
    assert_eq!(
        output["outcome"], "present_at_entry",
        "the command's real output must still be found: {output}"
    );

    guard.shutdown().await.expect("tmux fixture shuts down");
}