Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,84 @@ reusable, self-contained Avalonia terminal control package (it bundles
Avalonia applications. See the "NuGet package" section of
[docs/release.md](docs/release.md) for packaging and consumption details.

## Per-tab asciicast recording

Recording actions operate on the active terminal pane. `startRecording` captures
future PTY output, `stopRecording` retains the completed recording,
`saveRecording` opens a native Save As dialog and exports an asciicast `.cast`
file, and `replayRecording` replays the active pane's latest capture in a
separate read-only tab with its original timing. The recorder writes v2 by
default and the core API can also export v3 with
`recording.ToJson(AsciicastFormat.V3)`. `openRecording` detects and replays both
v2 and v3 files, even when the current pane has no recording. Pickers start in
the `recordings` directory beside `settings.json`.

All path-taking actions also accept an explicit `path` for keybindings,
command-line activation, and automation. A path on `startRecording` enables an
auto-save workflow: stopping the recording writes directly to that path.
Exported v2 files are compatible with v2 players such as `asciinema play`;
imported v3 files retain their relative event timing during replay.

To stream the active tab, open the command palette and choose **Start
streaming...**, paste the producer `ws://` or `wss://` URL, and choose
**Asciinema v3** (recommended), **Asciinema v2**, or **DVLS v2**. The recording
bar changes to **Live** while connected. Choose **Stop streaming** from the
palette (or use the bar's **Stop** button) to flush and close the stream. DT
remembers the URL only for the current process because producer URLs may
contain access tokens.

NuGet consumers can also stream an active recording to an asciicast-compatible
WebSocket push endpoint:

```csharp
await terminal.StartRecordingAsync(
new Uri("wss://gateway.example/jet/jrec/push/session?token=signed-token"));
// Use the terminal normally.
await terminal.StopRecordingAsync();
```

The control preserves authentication query parameters, adds
`fileType=asciicast`, and sends the visible starting screen plus subsequent
output as asciicast v2 JSONL text messages. Endpoint discovery and token
acquisition stay in the embedding application.

Official asciinema servers are also supported. Create a live stream through the
server API, then pass its `ws_producer_url` and select v2 or v3:

```csharp
await terminal.StartRecordingAsync(
new Uri(streamResponse.WsProducerUrl),
AsciicastFormat.V3);
```

This negotiates the matching `v2.asciicast` or `v3.asciicast` WebSocket
subprotocol. V3 output uses relative event deltas; the legacy URI-only overload
remains compatible with DVLS push URLs and server-side first-message detection.

While a recording is active, its tab shows a red recording indicator and the
active pane displays elapsed time, format, destination, and Stop/Save controls.
Stopped captures remain available from the same bar for saving or replay. A
yellow tab indicator marks an unsaved capture, and closing its pane, tab, or
window asks for confirmation even when normal close confirmations are disabled.

Replay tabs include pause/resume, restart, progress, and 0.5x-2x playback speed
controls. Replay remains read-only and can be closed without affecting the
original terminal session or recording.

The actions can be invoked from the command palette or assigned in
`settings.json`; for example:

```json
{
"command": {
"action": "startRecording",
"path": "%USERPROFILE%\\Desktop\\demo.cast",
"format": "v3"
},
"keys": "ctrl+shift+r"
}
```

## Compatibility inventory

### Safety and compatibility settings
Expand Down
1 change: 1 addition & 0 deletions src/Devolutions.Terminal.App/Actions/ActionScopeCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public static class ActionScopeCatalog
"identifyWindow" or
"newWindow" or
"openAbout" or
"openRecording" or
"openNewTabDropdown" or
"openSettings" or
"openSystemMenu" or
Expand Down
11 changes: 8 additions & 3 deletions src/Devolutions.Terminal.App/Actions/CloseConfirmationPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ namespace Devolutions.Terminal.App.Actions;

public static class CloseConfirmationPolicy
{
public static bool RequiresConfirmation(ConfirmOnClose policy, int runningSessions, bool automaticExit = false) =>
!automaticExit && policy switch
public static bool RequiresConfirmation(
ConfirmOnClose policy,
int runningSessions,
int unsavedRecordings = 0,
bool automaticExit = false) =>
unsavedRecordings > 0 ||
(!automaticExit && policy switch
{
ConfirmOnClose.Always => runningSessions > 0,
ConfirmOnClose.Automatic => runningSessions > 1,
_ => false,
};
});
}
23 changes: 20 additions & 3 deletions src/Devolutions.Terminal.App/Views/MainWindow.Lifecycle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,27 @@ private async Task<bool> ConfirmCloseAsync(IEnumerable<TerminalPane> panes, bool
var snapshot = panes.ToArray();
var sessions = snapshot.Select(pane => pane.Control.ProcessMetadata).ToArray();
var running = snapshot.Count(pane => pane.Control.IsRunning);
if (!CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose, running, automaticExit))
var unsavedRecordings = snapshot.Count(pane => pane.Control.HasUnsavedRecording);
if (!CloseConfirmationPolicy.RequiresConfirmation(
_settings.ConfirmOnClose,
running,
unsavedRecordings,
automaticExit))
{
return true;
}

var message = (running, unsavedRecordings) switch
{
(> 0, > 0) =>
$"Close {running} running terminal session(s)? " +
$"{unsavedRecordings} unsaved recording(s) will be discarded.",
(_, > 0) =>
$"Close terminal sessions? {unsavedRecordings} unsaved recording(s) will be discarded.",
_ => $"Close {running} running terminal session(s)? Unsaved work may be lost.",
};
return await _confirmationDialog.ShowAsync(this, "Close terminal sessions",
$"Close {running} running terminal session(s)? Unsaved work may be lost.", "Close sessions").ConfigureAwait(true) &&
message, "Close sessions").ConfigureAwait(true) &&
panes.SequenceEqual(snapshot) &&
snapshot.Select(pane => pane.Control.ProcessMetadata).SequenceEqual(sessions);
}
Expand Down Expand Up @@ -74,9 +88,11 @@ protected override void OnClosing(WindowClosingEventArgs e)
}

base.OnClosing(e);
var panes = _tabs.SelectMany(tab => tab.Panes.Leaves()).ToArray();
if (!e.Cancel && !_closeApproved &&
CloseConfirmationPolicy.RequiresConfirmation(_settings.ConfirmOnClose,
_tabs.SelectMany(tab => tab.Panes.Leaves()).Count(pane => pane.Control.IsRunning)))
panes.Count(pane => pane.Control.IsRunning),
panes.Count(pane => pane.Control.HasUnsavedRecording)))
{
e.Cancel = true;
if (!_closeConfirmationPending)
Expand All @@ -89,6 +105,7 @@ protected override void OnClosing(WindowClosingEventArgs e)
protected override async void OnClosed(EventArgs e)
{
_isClosed = true;
_recordingUiTimer.Stop();
if (!_layoutPersisted && _tabs.Count > 0)
{
TryPersistCurrentLayout(CaptureLayout());
Expand Down
58 changes: 58 additions & 0 deletions src/Devolutions.Terminal.App/Views/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,64 @@
</Border>
<Grid>
<Panel x:Name="TerminalHost" Background="#0C0C0C" />
<Border x:Name="RecordingStateBar"
IsVisible="False"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Margin="12"
Padding="10,6"
CornerRadius="6"
Background="#F02B2B2B">
<StackPanel Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<TextBlock x:Name="RecordingStatusText"
VerticalAlignment="Center"
FontWeight="SemiBold" />
<TextBlock x:Name="RecordingElapsedText"
VerticalAlignment="Center" />
<ProgressBar x:Name="ReplayProgress"
IsVisible="False"
Width="120"
Height="4"
Minimum="0"
VerticalAlignment="Center" />
<TextBlock x:Name="RecordingDestinationText"
MaxWidth="260"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
Foreground="#B8B8B8" />
<Button x:Name="RecordingStopButton"
IsVisible="False"
Content="Stop"
Click="RecordingStop_OnClick" />
<Button x:Name="RecordingSaveButton"
IsVisible="False"
Content="Save as..."
Click="RecordingSave_OnClick" />
<Button x:Name="RecordingReplayButton"
IsVisible="False"
Content="Replay"
Click="RecordingReplay_OnClick" />
<Button x:Name="ReplayPauseButton"
IsVisible="False"
Content="Pause"
Click="ReplayPause_OnClick" />
<Button x:Name="ReplayRestartButton"
IsVisible="False"
Content="Restart"
Click="ReplayRestart_OnClick" />
<ComboBox x:Name="ReplaySpeedComboBox"
IsVisible="False"
Width="72"
SelectionChanged="ReplaySpeed_OnSelectionChanged">
<ComboBoxItem Content="0.5x" Tag="0.5" />
<ComboBoxItem Content="1x" Tag="1" />
<ComboBoxItem Content="1.5x" Tag="1.5" />
<ComboBoxItem Content="2x" Tag="2" />
</ComboBox>
</StackPanel>
</Border>
<Border x:Name="FindBar"
IsVisible="False"
HorizontalAlignment="Right"
Expand Down
Loading
Loading