Skip to content

Commit c2d4cc3

Browse files
Add asciicast recording, replay, and live streaming (#50)
## Summary - add per-tab asciicast recording, save/export, and read-only replay workflows - support asciicast v2 and v3 parsing, serialization, timing, and initial visible-screen capture - add reusable WebSocket streaming for official asciinema v2/v3 and DVLS-compatible v2 endpoints - expose **Start streaming...** and **Stop streaming** through the command palette with a paste-URL/protocol dialog - protect unsaved recordings during tab/window closure and document the app and NuGet APIs ## Validation - `dotnet build Devolutions.Terminal.slnx -c Debug --nologo` - Settings action tests: 27 passed - App action tests: 28 passed - UI tests: 2 passed - Control streaming tests: 10 passed - real official asciinema-server v3 producer stream, live playback, clean stop, and persisted recording Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e617dcd commit c2d4cc3

19 files changed

Lines changed: 3019 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,84 @@ reusable, self-contained Avalonia terminal control package (it bundles
129129
Avalonia applications. See the "NuGet package" section of
130130
[docs/release.md](docs/release.md) for packaging and consumption details.
131131

132+
## Per-tab asciicast recording
133+
134+
Recording actions operate on the active terminal pane. `startRecording` captures
135+
future PTY output, `stopRecording` retains the completed recording,
136+
`saveRecording` opens a native Save As dialog and exports an asciicast `.cast`
137+
file, and `replayRecording` replays the active pane's latest capture in a
138+
separate read-only tab with its original timing. The recorder writes v2 by
139+
default and the core API can also export v3 with
140+
`recording.ToJson(AsciicastFormat.V3)`. `openRecording` detects and replays both
141+
v2 and v3 files, even when the current pane has no recording. Pickers start in
142+
the `recordings` directory beside `settings.json`.
143+
144+
All path-taking actions also accept an explicit `path` for keybindings,
145+
command-line activation, and automation. A path on `startRecording` enables an
146+
auto-save workflow: stopping the recording writes directly to that path.
147+
Exported v2 files are compatible with v2 players such as `asciinema play`;
148+
imported v3 files retain their relative event timing during replay.
149+
150+
To stream the active tab, open the command palette and choose **Start
151+
streaming...**, paste the producer `ws://` or `wss://` URL, and choose
152+
**Asciinema v3** (recommended), **Asciinema v2**, or **DVLS v2**. The recording
153+
bar changes to **Live** while connected. Choose **Stop streaming** from the
154+
palette (or use the bar's **Stop** button) to flush and close the stream. DT
155+
remembers the URL only for the current process because producer URLs may
156+
contain access tokens.
157+
158+
NuGet consumers can also stream an active recording to an asciicast-compatible
159+
WebSocket push endpoint:
160+
161+
```csharp
162+
await terminal.StartRecordingAsync(
163+
new Uri("wss://gateway.example/jet/jrec/push/session?token=signed-token"));
164+
// Use the terminal normally.
165+
await terminal.StopRecordingAsync();
166+
```
167+
168+
The control preserves authentication query parameters, adds
169+
`fileType=asciicast`, and sends the visible starting screen plus subsequent
170+
output as asciicast v2 JSONL text messages. Endpoint discovery and token
171+
acquisition stay in the embedding application.
172+
173+
Official asciinema servers are also supported. Create a live stream through the
174+
server API, then pass its `ws_producer_url` and select v2 or v3:
175+
176+
```csharp
177+
await terminal.StartRecordingAsync(
178+
new Uri(streamResponse.WsProducerUrl),
179+
AsciicastFormat.V3);
180+
```
181+
182+
This negotiates the matching `v2.asciicast` or `v3.asciicast` WebSocket
183+
subprotocol. V3 output uses relative event deltas; the legacy URI-only overload
184+
remains compatible with DVLS push URLs and server-side first-message detection.
185+
186+
While a recording is active, its tab shows a red recording indicator and the
187+
active pane displays elapsed time, format, destination, and Stop/Save controls.
188+
Stopped captures remain available from the same bar for saving or replay. A
189+
yellow tab indicator marks an unsaved capture, and closing its pane, tab, or
190+
window asks for confirmation even when normal close confirmations are disabled.
191+
192+
Replay tabs include pause/resume, restart, progress, and 0.5x-2x playback speed
193+
controls. Replay remains read-only and can be closed without affecting the
194+
original terminal session or recording.
195+
196+
The actions can be invoked from the command palette or assigned in
197+
`settings.json`; for example:
198+
199+
```json
200+
{
201+
"command": {
202+
"action": "startRecording",
203+
"path": "%USERPROFILE%\\Desktop\\demo.cast",
204+
"format": "v3"
205+
},
206+
"keys": "ctrl+shift+r"
207+
}
208+
```
209+
132210
## Compatibility inventory
133211

134212
### Safety and compatibility settings

‎src/Devolutions.Terminal.App/Actions/ActionScopeCatalog.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public static class ActionScopeCatalog
1717
"identifyWindow" or
1818
"newWindow" or
1919
"openAbout" or
20+
"openRecording" or
2021
"openNewTabDropdown" or
2122
"openSettings" or
2223
"openSystemMenu" or

‎src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@ namespace Devolutions.Terminal.App.Actions;
44

55
public static class CloseConfirmationPolicy
66
{
7-
public static bool RequiresConfirmation(ConfirmOnClose policy, int runningSessions, bool automaticExit = false) =>
8-
!automaticExit && policy switch
7+
public static bool RequiresConfirmation(
8+
ConfirmOnClose policy,
9+
int runningSessions,
10+
int unsavedRecordings = 0,
11+
bool automaticExit = false) =>
12+
unsavedRecordings > 0 ||
13+
(!automaticExit && policy switch
914
{
1015
ConfirmOnClose.Always => runningSessions > 0,
1116
ConfirmOnClose.Automatic => runningSessions > 1,
1217
_ => false,
13-
};
18+
});
1419
}

‎src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,27 @@ private async Task<bool> ConfirmCloseAsync(IEnumerable<TerminalPane> panes, bool
3434
var snapshot = panes.ToArray();
3535
var sessions = snapshot.Select(pane => pane.Control.ProcessMetadata).ToArray();
3636
var running = snapshot.Count(pane => pane.Control.IsRunning);
37-
if (!CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose, running, automaticExit))
37+
var unsavedRecordings = snapshot.Count(pane => pane.Control.HasUnsavedRecording);
38+
if (!CloseConfirmationPolicy.RequiresConfirmation(
39+
_settings.ConfirmOnClose,
40+
running,
41+
unsavedRecordings,
42+
automaticExit))
3843
{
3944
return true;
4045
}
4146

47+
var message = (running, unsavedRecordings) switch
48+
{
49+
(> 0, > 0) =>
50+
$"Close {running} running terminal session(s)? " +
51+
$"{unsavedRecordings} unsaved recording(s) will be discarded.",
52+
(_, > 0) =>
53+
$"Close terminal sessions? {unsavedRecordings} unsaved recording(s) will be discarded.",
54+
_ => $"Close {running} running terminal session(s)? Unsaved work may be lost.",
55+
};
4256
return await _confirmationDialog.ShowAsync(this, "Close terminal sessions",
43-
$"Close {running} running terminal session(s)? Unsaved work may be lost.", "Close sessions").ConfigureAwait(true) &&
57+
message, "Close sessions").ConfigureAwait(true) &&
4458
panes.SequenceEqual(snapshot) &&
4559
snapshot.Select(pane => pane.Control.ProcessMetadata).SequenceEqual(sessions);
4660
}
@@ -74,9 +88,11 @@ protected override void OnClosing(WindowClosingEventArgs e)
7488
}
7589

7690
base.OnClosing(e);
91+
var panes = _tabs.SelectMany(tab => tab.Panes.Leaves()).ToArray();
7792
if (!e.Cancel && !_closeApproved &&
7893
CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose,
79-
_tabs.SelectMany(tab => tab.Panes.Leaves()).Count(pane => pane.Control.IsRunning)))
94+
panes.Count(pane => pane.Control.IsRunning),
95+
panes.Count(pane => pane.Control.HasUnsavedRecording)))
8096
{
8197
e.Cancel = true;
8298
if (!_closeConfirmationPending)
@@ -89,6 +105,7 @@ protected override void OnClosing(WindowClosingEventArgs e)
89105
protected override async void OnClosed(EventArgs e)
90106
{
91107
_isClosed = true;
108+
_recordingUiTimer.Stop();
92109
if (!_layoutPersisted && _tabs.Count > 0)
93110
{
94111
TryPersistCurrentLayout(CaptureLayout());

‎src/Devolutions.Terminal.App/Views/MainWindow.axaml‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,64 @@
9999
</Border>
100100
<Grid>
101101
<Panel x:Name="TerminalHost" Background="#0C0C0C" />
102+
<Border x:Name="RecordingStateBar"
103+
IsVisible="False"
104+
HorizontalAlignment="Center"
105+
VerticalAlignment="Bottom"
106+
Margin="12"
107+
Padding="10,6"
108+
CornerRadius="6"
109+
Background="#F02B2B2B">
110+
<StackPanel Orientation="Horizontal"
111+
Spacing="8"
112+
VerticalAlignment="Center">
113+
<TextBlock x:Name="RecordingStatusText"
114+
VerticalAlignment="Center"
115+
FontWeight="SemiBold" />
116+
<TextBlock x:Name="RecordingElapsedText"
117+
VerticalAlignment="Center" />
118+
<ProgressBar x:Name="ReplayProgress"
119+
IsVisible="False"
120+
Width="120"
121+
Height="4"
122+
Minimum="0"
123+
VerticalAlignment="Center" />
124+
<TextBlock x:Name="RecordingDestinationText"
125+
MaxWidth="260"
126+
TextTrimming="CharacterEllipsis"
127+
VerticalAlignment="Center"
128+
Foreground="#B8B8B8" />
129+
<Button x:Name="RecordingStopButton"
130+
IsVisible="False"
131+
Content="Stop"
132+
Click="RecordingStop_OnClick" />
133+
<Button x:Name="RecordingSaveButton"
134+
IsVisible="False"
135+
Content="Save as..."
136+
Click="RecordingSave_OnClick" />
137+
<Button x:Name="RecordingReplayButton"
138+
IsVisible="False"
139+
Content="Replay"
140+
Click="RecordingReplay_OnClick" />
141+
<Button x:Name="ReplayPauseButton"
142+
IsVisible="False"
143+
Content="Pause"
144+
Click="ReplayPause_OnClick" />
145+
<Button x:Name="ReplayRestartButton"
146+
IsVisible="False"
147+
Content="Restart"
148+
Click="ReplayRestart_OnClick" />
149+
<ComboBox x:Name="ReplaySpeedComboBox"
150+
IsVisible="False"
151+
Width="72"
152+
SelectionChanged="ReplaySpeed_OnSelectionChanged">
153+
<ComboBoxItem Content="0.5x" Tag="0.5" />
154+
<ComboBoxItem Content="1x" Tag="1" />
155+
<ComboBoxItem Content="1.5x" Tag="1.5" />
156+
<ComboBoxItem Content="2x" Tag="2" />
157+
</ComboBox>
158+
</StackPanel>
159+
</Border>
102160
<Border x:Name="FindBar"
103161
IsVisible="False"
104162
HorizontalAlignment="Right"

0 commit comments

Comments
 (0)