Skip to content

Commit fa4516e

Browse files
committed
feat(clipboard): OSC 52 remote copy + bracketed paste
Make copy/paste work over SSH, not just on a local desktop. Copy: ClipboardHelper.SetText now emits OSC 52 (reaching the LOCAL clipboard over SSH) in addition to the local tool — belt-and-braces, so it works in any session. tmux is passthrough-wrapped transparently; screen is detected and skipped; the payload is size-capped (~74KB) to avoid terminal truncation. New Osc52Mode (Auto/Enabled/Disabled) override plus public TerminalCapabilities session flags (IsRemoteSession/IsTmux/IsScreen/SupportsOsc52). The static ClipboardHelper reaches the terminal via an emitter delegate the driver registers at Start() and unregisters at Stop(), so it stays driver-agnostic and writes under the same console lock as the renderer. Paste: enable bracketed paste (?2004h/?2004l, symmetric in Start/Stop/emergency cleanup); AnsiInputParser recognizes ESC[200~..ESC[201~ and emits an atomic, UTF-8-safe PasteInputEvent (chunk-split tolerant; an unterminated paste is flushed, not stuck). The event flows reader thread -> concurrent paste queue -> UI thread, never touching controls off-thread. Paste is centralized behind a new IPasteTarget, routed by the window from BOTH bracketed paste and Ctrl+V — replacing three duplicate per-control Ctrl+V handlers (MultilineEdit, Prompt, Table) and fixing Ctrl+V consistency. Multi-line paste inserts as content (no runaway auto-indent); read-only editors and non-editing table cells no-op. Fully backward-compatible: local-desktop behavior preserved (local tool still writes); GetText unchanged; all new public surface is additive.
1 parent 43098f3 commit fa4516e

27 files changed

Lines changed: 720 additions & 56 deletions

SharpConsoleUI.Tests/Controls/MarkupControlCopyTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
namespace SharpConsoleUI.Tests.Controls;
2020

21+
[Collection("EnvSerial")]
2122
public class MarkupControlCopyTests
2223
{
2324
private static MouseEventArgs Mouse(int x, int y, params MouseFlags[] flags)

SharpConsoleUI.Tests/Core/WindowCopySelectionTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
namespace SharpConsoleUI.Tests.Core;
2020

21+
[Collection("EnvSerial")]
2122
public class WindowCopySelectionTests
2223
{
2324
private static MouseEventArgs Mouse(int x, int y, params MouseFlags[] flags)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using System.Text;
2+
using SharpConsoleUI.Drivers.Input;
3+
using Xunit;
4+
5+
namespace SharpConsoleUI.Tests.Drivers;
6+
7+
public class BracketedPasteParserTests
8+
{
9+
private static List<InputEvent> Parse(string ascii)
10+
{
11+
var parser = new AnsiInputParser();
12+
var bytes = Encoding.UTF8.GetBytes(ascii);
13+
return parser.Parse(bytes.AsSpan(), bytes.Length);
14+
}
15+
16+
[Fact]
17+
public void Paste_DeliversSingleEvent_NoPerCharKeys()
18+
{
19+
var events = Parse("\x1b[200~hello\nworld\x1b[201~");
20+
var pastes = events.OfType<PasteInputEvent>().ToList();
21+
Assert.Single(pastes);
22+
Assert.Equal("hello\nworld", pastes[0].Text);
23+
Assert.Empty(events.OfType<KeyInputEvent>());
24+
}
25+
26+
[Fact]
27+
public void Paste_ContentWithCsiLikeBytes_IsLiteral()
28+
{
29+
var events = Parse("\x1b[200~a[1;5B~c\x1b[201~");
30+
var pastes = events.OfType<PasteInputEvent>().ToList();
31+
Assert.Single(pastes);
32+
Assert.Equal("a[1;5B~c", pastes[0].Text);
33+
}
34+
35+
[Fact]
36+
public void Paste_SplitAcrossChunks_Reassembles()
37+
{
38+
var parser = new AnsiInputParser();
39+
var all = new List<InputEvent>();
40+
foreach (var chunk in new[] { "\x1b[2", "00~hel", "lo\x1b[20", "1~" })
41+
{
42+
var b = Encoding.UTF8.GetBytes(chunk);
43+
all.AddRange(parser.Parse(b.AsSpan(), b.Length));
44+
}
45+
var pastes = all.OfType<PasteInputEvent>().ToList();
46+
Assert.Single(pastes);
47+
Assert.Equal("hello", pastes[0].Text);
48+
}
49+
50+
[Fact]
51+
public void Paste_Empty_DeliversEmptyText()
52+
{
53+
var events = Parse("\x1b[200~\x1b[201~");
54+
Assert.Single(events.OfType<PasteInputEvent>());
55+
Assert.Equal("", events.OfType<PasteInputEvent>().Single().Text);
56+
}
57+
58+
[Fact]
59+
public void Bare_EndMarker_NoOpenPaste_IsIgnored()
60+
{
61+
var events = Parse("\x1b[201~");
62+
Assert.Empty(events.OfType<PasteInputEvent>());
63+
}
64+
65+
[Fact]
66+
public void NormalKeysAfterPaste_StillParse()
67+
{
68+
var events = Parse("\x1b[200~x\x1b[201~A");
69+
Assert.Single(events.OfType<PasteInputEvent>());
70+
Assert.Contains(events.OfType<KeyInputEvent>(), k => k.KeyInfo.KeyChar == 'A');
71+
}
72+
73+
[Fact]
74+
public void UnterminatedPaste_FlushesAccumulatedAndResets()
75+
{
76+
var parser = new AnsiInputParser();
77+
var open = Encoding.UTF8.GetBytes("\x1b[200~partial");
78+
parser.Parse(open.AsSpan(), open.Length); // no end marker
79+
80+
var flushed = parser.Flush();
81+
var paste = Assert.Single(flushed.OfType<PasteInputEvent>());
82+
Assert.Equal("partial", paste.Text);
83+
84+
// Parser recovered to Ground: a normal key after flush parses correctly.
85+
var after = Encoding.UTF8.GetBytes("A");
86+
var ev = parser.Parse(after.AsSpan(), after.Length);
87+
Assert.Contains(ev.OfType<KeyInputEvent>(), k => k.KeyInfo.KeyChar == 'A');
88+
}
89+
90+
[Fact]
91+
public void Paste_Utf8Content_Decodes()
92+
{
93+
var parser = new AnsiInputParser();
94+
var bytes = Encoding.UTF8.GetBytes("\x1b[200~café 漢字\x1b[201~");
95+
var events = parser.Parse(bytes.AsSpan(), bytes.Length);
96+
Assert.Equal("café 漢字", events.OfType<PasteInputEvent>().Single().Text);
97+
}
98+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using SharpConsoleUI.Helpers;
2+
using Xunit;
3+
4+
namespace SharpConsoleUI.Tests.Helpers;
5+
6+
[Collection("EnvSerial")]
7+
public class ClipboardOsc52Tests : IDisposable
8+
{
9+
private readonly List<string> _emitted = new();
10+
11+
public ClipboardOsc52Tests()
12+
{
13+
ClipboardHelper.ForceBackendForTests(ClipboardBackend.InternalFallback);
14+
ClipboardHelper.RegisterOsc52Emitter(s => _emitted.Add(s));
15+
ClipboardHelper.Osc52Mode = Osc52Mode.Auto;
16+
TerminalCapabilities.SetOsc52Override(null);
17+
}
18+
19+
public void Dispose()
20+
{
21+
ClipboardHelper.RegisterOsc52Emitter(null);
22+
ClipboardHelper.Osc52Mode = Osc52Mode.Auto;
23+
TerminalCapabilities.SetOsc52Override(null);
24+
ClipboardHelper.MaxOsc52Bytes = Osc52.DefaultMaxBytes;
25+
}
26+
27+
[Fact]
28+
public void Auto_WithOsc52Supported_EmitsAndSetsLocalBuffer()
29+
{
30+
TerminalCapabilities.SetOsc52Override(true);
31+
ClipboardHelper.SetText("hello");
32+
Assert.Single(_emitted);
33+
Assert.Contains("\x1b]52;c;", _emitted[0]);
34+
Assert.Equal("hello", ClipboardHelper.GetText());
35+
}
36+
37+
[Fact]
38+
public void Disabled_NeverEmits_ButLocalStillSet()
39+
{
40+
ClipboardHelper.Osc52Mode = Osc52Mode.Disabled;
41+
ClipboardHelper.SetText("hello");
42+
Assert.Empty(_emitted);
43+
Assert.Equal("hello", ClipboardHelper.GetText());
44+
}
45+
46+
[Fact]
47+
public void Enabled_OverridesUnsupported()
48+
{
49+
ClipboardHelper.Osc52Mode = Osc52Mode.Enabled;
50+
TerminalCapabilities.SetOsc52Override(false);
51+
ClipboardHelper.SetText("hi");
52+
Assert.Single(_emitted);
53+
}
54+
55+
[Fact]
56+
public void Auto_WhenOsc52Unsupported_DoesNotEmit_ButLocalStillSet()
57+
{
58+
TerminalCapabilities.SetOsc52Override(false);
59+
ClipboardHelper.SetText("hi");
60+
Assert.Empty(_emitted);
61+
Assert.Equal("hi", ClipboardHelper.GetText());
62+
}
63+
64+
[Fact]
65+
public void OverCap_DoesNotEmit_ButLocalStillSet()
66+
{
67+
TerminalCapabilities.SetOsc52Override(true);
68+
ClipboardHelper.MaxOsc52Bytes = 8;
69+
ClipboardHelper.SetText(new string('x', 1000));
70+
Assert.Empty(_emitted);
71+
Assert.Equal(new string('x', 1000), ClipboardHelper.GetText());
72+
}
73+
74+
[Fact]
75+
public void EmitterThrows_SetTextStillSucceeds()
76+
{
77+
TerminalCapabilities.SetOsc52Override(true);
78+
ClipboardHelper.RegisterOsc52Emitter(_ => throw new InvalidOperationException("boom"));
79+
ClipboardHelper.SetText("safe");
80+
Assert.Equal("safe", ClipboardHelper.GetText());
81+
}
82+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using System.Text;
2+
using SharpConsoleUI.Helpers;
3+
using Xunit;
4+
5+
namespace SharpConsoleUI.Tests.Helpers;
6+
7+
public class Osc52Tests
8+
{
9+
[Fact]
10+
public void BuildSequence_Plain_IsByteExact()
11+
{
12+
// "Hello" -> base64 "SGVsbG8="
13+
var seq = Osc52.BuildSequence("Hello", tmuxWrap: false, maxBytes: 1000);
14+
Assert.Equal("\x1b]52;c;SGVsbG8=\x07", seq);
15+
}
16+
17+
[Fact]
18+
public void BuildSequence_UsesClipboardSelection_c()
19+
{
20+
var seq = Osc52.BuildSequence("x", tmuxWrap: false, maxBytes: 1000)!;
21+
Assert.StartsWith("\x1b]52;c;", seq);
22+
}
23+
24+
[Fact]
25+
public void BuildSequence_RoundTripsUtf8AndEmoji()
26+
{
27+
string text = "café 📦 漢字";
28+
var seq = Osc52.BuildSequence(text, tmuxWrap: false, maxBytes: 10000)!;
29+
int start = seq.IndexOf(";c;", StringComparison.Ordinal) + 3;
30+
int end = seq.IndexOf('\x07');
31+
string b64 = seq.Substring(start, end - start);
32+
string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(b64));
33+
Assert.Equal(text, decoded);
34+
}
35+
36+
[Fact]
37+
public void BuildSequence_TmuxWrap_DoublesEscAndWraps()
38+
{
39+
var seq = Osc52.BuildSequence("Hi", tmuxWrap: true, maxBytes: 1000)!;
40+
Assert.Equal("\x1bPtmux;\x1b\x1b]52;c;SGk=\x07\x1b\\", seq);
41+
}
42+
43+
[Fact]
44+
public void BuildSequence_OverMaxBytes_ReturnsNull()
45+
{
46+
string text = new string('a', 100);
47+
Assert.Null(Osc52.BuildSequence(text, tmuxWrap: false, maxBytes: 10));
48+
}
49+
50+
[Fact]
51+
public void BuildSequence_AtExactCap_Emits()
52+
{
53+
string text = "abc"; // base64 "YWJj" = 4 chars
54+
Assert.NotNull(Osc52.BuildSequence(text, tmuxWrap: false, maxBytes: 4));
55+
Assert.Null(Osc52.BuildSequence(text, tmuxWrap: false, maxBytes: 3));
56+
}
57+
58+
[Fact]
59+
public void BuildSequence_NullInput_TreatedAsEmpty()
60+
{
61+
var seq = Osc52.BuildSequence(null!, tmuxWrap: false, maxBytes: 1000);
62+
Assert.Equal("\x1b]52;c;\x07", seq);
63+
}
64+
65+
[Fact]
66+
public void BuildSequence_EmptyString_EmitsEvenWithZeroCap()
67+
{
68+
// base64 of "" is "" (length 0), so 0 > maxBytes(0) is false -> emits.
69+
var seq = Osc52.BuildSequence("", tmuxWrap: false, maxBytes: 0);
70+
Assert.Equal("\x1b]52;c;\x07", seq);
71+
}
72+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using SharpConsoleUI.Helpers;
2+
using Xunit;
3+
4+
namespace SharpConsoleUI.Tests.Helpers;
5+
6+
[CollectionDefinition("EnvSerial", DisableParallelization = true)]
7+
public class EnvSerialCollection { }
8+
9+
[Collection("EnvSerial")]
10+
public class TerminalCapabilitiesSessionTests
11+
{
12+
private static void WithEnv(Action body, params (string Key, string? Val)[] vars)
13+
{
14+
var saved = vars.Select(v => (v.Key, Old: Environment.GetEnvironmentVariable(v.Key))).ToList();
15+
try
16+
{
17+
foreach (var v in vars) Environment.SetEnvironmentVariable(v.Key, v.Val);
18+
body();
19+
}
20+
finally
21+
{
22+
foreach (var s in saved) Environment.SetEnvironmentVariable(s.Key, s.Old);
23+
}
24+
}
25+
26+
[Fact]
27+
public void Detects_Ssh_Tmux_Screen_AndOsc52()
28+
{
29+
WithEnv(() =>
30+
{
31+
TerminalCapabilities.DetectClipboardEnvironmentForTests();
32+
Assert.True(TerminalCapabilities.IsRemoteSession);
33+
Assert.True(TerminalCapabilities.IsTmux);
34+
Assert.False(TerminalCapabilities.IsScreen);
35+
Assert.True(TerminalCapabilities.SupportsOsc52); // tmux is fine (we wrap)
36+
},
37+
("SSH_TTY", "/dev/pts/3"), ("TMUX", "/tmp/tmux-1000/default,1,0"), ("STY", null));
38+
}
39+
40+
[Fact]
41+
public void Screen_DisablesOsc52()
42+
{
43+
WithEnv(() =>
44+
{
45+
TerminalCapabilities.DetectClipboardEnvironmentForTests();
46+
Assert.True(TerminalCapabilities.IsScreen);
47+
Assert.False(TerminalCapabilities.SupportsOsc52);
48+
},
49+
("STY", "12345.pts-0.host"), ("SSH_TTY", null), ("TMUX", null));
50+
}
51+
52+
[Fact]
53+
public void Local_NoSshVars_IsNotRemote()
54+
{
55+
WithEnv(() =>
56+
{
57+
TerminalCapabilities.DetectClipboardEnvironmentForTests();
58+
Assert.False(TerminalCapabilities.IsRemoteSession);
59+
Assert.True(TerminalCapabilities.SupportsOsc52);
60+
},
61+
("SSH_TTY", null), ("SSH_CONNECTION", null), ("TMUX", null), ("STY", null));
62+
}
63+
64+
[Fact]
65+
public void SshConnection_AloneMarksRemote()
66+
{
67+
WithEnv(() =>
68+
{
69+
TerminalCapabilities.DetectClipboardEnvironmentForTests();
70+
Assert.True(TerminalCapabilities.IsRemoteSession);
71+
},
72+
("SSH_TTY", null), ("SSH_CONNECTION", "1.2.3.4 5 6.7.8.9 22"), ("TMUX", null), ("STY", null));
73+
}
74+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using SharpConsoleUI;
2+
using SharpConsoleUI.Controls;
3+
using SharpConsoleUI.Tests.Infrastructure;
4+
using SharpConsoleUI.Windows;
5+
using Xunit;
6+
7+
namespace SharpConsoleUI.Tests.InputHandling;
8+
9+
[Collection("EnvSerial")]
10+
public class PasteRoutingTests
11+
{
12+
[Fact]
13+
public void BracketedPaste_ReachesFocusedMultilineEdit()
14+
{
15+
var system = TestWindowSystemBuilder.CreateTestSystem();
16+
var window = new Window(system);
17+
var editor = new MultilineEditControl();
18+
window.AddControl(editor);
19+
system.WindowStateService.AddWindow(window);
20+
window.FocusManager.SetFocus(editor, FocusReason.Programmatic);
21+
22+
window.EventDispatcher!.ProcessPaste("alpha\nbeta");
23+
24+
Assert.Contains("alpha", editor.GetContent());
25+
Assert.Contains("beta", editor.GetContent());
26+
}
27+
28+
[Fact]
29+
public void CtrlV_RoutesThroughPasteTarget()
30+
{
31+
SharpConsoleUI.Helpers.ClipboardHelper.ForceBackendForTests(
32+
SharpConsoleUI.Helpers.ClipboardBackend.InternalFallback);
33+
SharpConsoleUI.Helpers.ClipboardHelper.SetText("pasted-via-ctrlv");
34+
35+
var system = TestWindowSystemBuilder.CreateTestSystem();
36+
var window = new Window(system);
37+
var editor = new MultilineEditControl();
38+
window.AddControl(editor);
39+
system.WindowStateService.AddWindow(window);
40+
window.FocusManager.SetFocus(editor, FocusReason.Programmatic);
41+
42+
var ctrlV = new ConsoleKeyInfo('\u0016', ConsoleKey.V, false, false, true);
43+
window.EventDispatcher!.ProcessInput(ctrlV);
44+
45+
Assert.Contains("pasted-via-ctrlv", editor.GetContent());
46+
}
47+
}

0 commit comments

Comments
 (0)