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
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0] - 2026-06-07
## [1.0.0] - 2026-06-08

### Added
- **Run a Snip, not just copy it.** Snip cards gain a **Run** action beside Copy.
Running walks the same parameter-fill flow, then shows a dry-run preview of the
exact resolved command — alongside the shell and working directory it will execute
under — before anything runs.
- **Live terminal with full fidelity.** Runs execute under a real pseudo-terminal
(ConPTY), so colours, spinners, progress bars and interactive prompts (e.g.
Spectre.Console selection/confirmation prompts) all behave as in a real terminal.
Output streams into an embedded terminal you can type into; a Cancel button stops
the run, and an exit-code badge and elapsed time appear when it finishes.
- **Clean, searchable run history.** Every run is recorded with a clean plain-text
transcript — no escape codes, no cursor artefacts, no stacked spinner frames — plus
the command, timestamp, duration and exit code. A new **History** view (next to
Settings) lists runs newest-first with text search; open one to replay its output in
full colour, or run it again.
- **Per-CLI execution settings.** The CLI editor gains a **shell** (Command Prompt,
Windows PowerShell, PowerShell, Bash, or a custom shell), an optional **executable
path** and **working directory** (both with Browse…). All are optional and validated
only at Run time, so a CLI stays useful for authoring before its tool is installed.
A Snip can override its CLI's shell and working directory.
- **History settings.** Configure how many runs to keep per Snip and the maximum
captured output size (runs are truncated beyond it).
- **Acknowledgements.** Settings → About lists the open-source projects Snipdeck is
built on, each linking to its repository; the README carries the same list.

### Added
- **Snipdeck branding.** The app now ships with its own icon — a faceted green
Expand Down
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ Conventions:
top entry).
- **Pane body**: the tag list, single-select, scoped to the selected CLI (shows all
tags when on Home). Changing the CLI reloads this list.
- **Pane footer**: Settings. About is the **last** `SettingsExpander` inside Settings.
- **Pane footer**: Settings (and a History entry — see command execution). Inside
Settings, the **About** expander is followed by an **Acknowledgements** expander
(the open-source projects Snipdeck is built on), which is last.
- The content area is state-driven by the switcher:
- **Home** (`All / Home` selected): the CLI card launcher + most-used Snips.
- **CLI selected**: the Snip list for that CLI, filtered by the selected tag.
Expand Down
7 changes: 7 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,18 @@
<PackageVersion Include="Markdig" Version="1.2.0" />
</ItemGroup>

<!-- Execution (command-execution feature; net10.0, no UI) -->
<ItemGroup>
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageVersion Include="Porta.Pty" Version="1.0.7" />
</ItemGroup>

<!-- App (WinUI 3 head) -->
<ItemGroup>
<PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.251219" />
<PackageVersion Include="H.NotifyIcon.WinUI" Version="2.4.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
<PackageVersion Include="Velopack" Version="1.1.1" />
Expand Down
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

A native Windows desktop app for managing parameterised CLI command snippets
("Snips"), organised by the CLI they belong to. Browse a CLI, pick a Snip, fill
its arguments, and copy the resolved command to the clipboard.
its arguments, and copy the resolved command to the clipboard — or run it in place
and watch it live, with a clean, searchable run history.

Conceptually inspired by
[SnipCommand](https://github.com/gurayyarar/SnipCommand), with one defining
Expand All @@ -19,11 +20,17 @@ to exactly one CLI (e.g. `pl-app`, `mpt-app`, `inv-app`).

## Status

Pre-1.0. **v0.2.0** is the latest release — see
[Releases](https://github.com/StuartMeeks/Snipdeck/releases). It contains
the full v1 feature set: browse CLIs and Snips, author Snips with structured
parameters, fill and copy resolved commands, global hotkey, system tray with
close-to-tray, theme switching, and Velopack-backed self-update.
**v1.0.0** is the latest release — see
[Releases](https://github.com/StuartMeeks/Snipdeck/releases). It contains the full
v1 feature set: browse CLIs and Snips, author Snips with structured parameters, fill
and copy resolved commands, global hotkey, system tray with close-to-tray, theme
switching, and Velopack-backed self-update.

It also runs Snips: execute one in its configured shell via a real pseudo-terminal —
so colours, spinners, progress bars and interactive prompts all work — watch it live,
and keep a clean plain-text run history you can search and replay. Per-CLI shell,
executable path and working directory configure how runs launch. See
[`CHANGELOG.md`](CHANGELOG.md).

## Install

Expand Down Expand Up @@ -103,6 +110,26 @@ command templates are day to day. Snipdeck reimagines that idea as a native
Windows app organised around the CLI each command belongs to. A
[SnipCommand import tool](tools/Snipdeck.Importer) ships in this repo.

Snipdeck is built on the work of these open-source projects, with thanks to
their authors and maintainers:

- [.NET Community Toolkit](https://github.com/CommunityToolkit/dotnet) — MVVM source generators and helpers (MIT)
- [Coverlet](https://github.com/coverlet-coverage/coverlet) — code-coverage collection (MIT)
- [H.NotifyIcon](https://github.com/HavenDV/H.NotifyIcon) — system-tray icon and menu (MIT)
- [Jdenticon](https://github.com/dmester/jdenticon-net) — identicons for CLIs without a custom icon (MIT)
- [Markdig](https://github.com/xoofx/markdig) — Markdown rendering for Snip descriptions (BSD-2-Clause)
- [Microsoft.Extensions.DependencyInjection](https://github.com/dotnet/runtime) — dependency injection (MIT)
- [Nerdbank.GitVersioning](https://github.com/dotnet/Nerdbank.GitVersioning) — git-derived versioning (MIT)
- [Porta.Pty](https://github.com/tomlm/Porta.Pty) — cross-platform pseudo-terminal (ConPTY) for running commands (MIT)
- [Spectre.Console](https://github.com/spectreconsole/spectre.console) — console UI for the import tool (MIT)
- [SQLite](https://www.sqlite.org) & [Microsoft.Data.Sqlite](https://github.com/dotnet/efcore) — execution-history storage (Public Domain / MIT)
- [Velopack](https://github.com/velopack/velopack) — installer and self-update (MIT)
- [WebView2](https://learn.microsoft.com/microsoft-edge/webview2/) — hosts the xterm.js live terminal (Microsoft)
- [Windows App SDK & WinUI 3](https://github.com/microsoft/WindowsAppSDK) — the native Windows UI framework (MIT)
- [Windows Community Toolkit](https://github.com/CommunityToolkit/Windows) — `SettingsCard` / `SettingsExpander` controls (MIT)
- [xterm.js](https://github.com/xtermjs/xterm.js) — renders the live terminal output (MIT)
- [xUnit](https://github.com/xunit/xunit) — unit-testing framework (Apache-2.0)

## Licence

Licensed under the Apache Licence, Version 2.0. See [`LICENSE`](LICENSE).
2 changes: 2 additions & 0 deletions Snipdeck.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
<Platform Project="x64" />
</Project>
<Project Path="src/Snipdeck.Core/Snipdeck.Core.csproj" />
<Project Path="src/Snipdeck.Execution/Snipdeck.Execution.csproj" />
<Project Path="tests/Snipdeck.Core.Tests/Snipdeck.Core.Tests.csproj" />
<Project Path="tests/Snipdeck.Execution.Tests/Snipdeck.Execution.Tests.csproj" />
<Project Path="tools/Snipdeck.Importer/Snipdeck.Importer.csproj" />
<Project Path="tools/Snipdeck.Importer.Tests/Snipdeck.Importer.Tests.csproj" />
</Solution>
262 changes: 262 additions & 0 deletions docs/exec-feature-chat-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
# Snipdeck — Command Execution & History Feature

Read `CLAUDE.md` first. This document adds a specific feature on top of that
foundation. All Core/App boundary rules, naming conventions, and working
agreements from `CLAUDE.md` apply here.

---

## Feature goal

Allow the user to execute a resolved Snip command directly from Snipdeck, watch
it run with full live terminal output (colours, spinners, progress bars), and have
a clean plain-text record stored in history when it finishes. The live view must
be faithful — the command should behave as if it is running in a real terminal.
The stored history must be clean — no ANSI escape codes, no cursor-movement
artefacts, no spinner frames stacked on top of each other.

---

## Architecture: two concurrent consumers of one byte stream

```
PTY (Porta.Pty — ConPTY)
├──► WebView2 + xterm.js live view — raw VT bytes, full fidelity
└──► in-memory buffer
└──► VtOutputProcessor on exit → clean text → history
```

The PTY makes the child process believe it is talking to a real terminal.
`Console.IsOutputRedirected` stays `false`, so tools built on Spectre.Console,
Progress, etc. render fully. The raw VT byte stream is split: every chunk is
forwarded to xterm.js for live display AND appended to a buffer. When the
process exits, `VtOutputProcessor` collapses the buffer into clean text for
storage.

**Start with the pipe path first.** Many .NET CLIs (including those built on
Spectre.Console) self-demote when `Console.IsOutputRedirected` is `true` —
they drop colour and animations automatically. Test `pl-app`, `mpt-app`, and
`inv-app` via `PipeCommandRunner` before reaching for the PTY. If they
self-demote, `VtOutputProcessor` still strips residual ANSI and the pipe path
is sufficient. Add `PtyCommandRunner` only when a specific CLI proves it
does not self-demote.

---

## New domain model

Add to `Snipdeck.Core/Models/`:

```csharp
public sealed class CommandHistoryEntry
{
public Guid Id { get; init; } = Guid.NewGuid();
public Guid CliId { get; init; } // which CLI
public Guid SnipId { get; init; } // which Snip
public string ResolvedCommand { get; init; } = string.Empty;
public DateTimeOffset ExecutedAt { get; init; }
public int DurationMs { get; init; }
public int ExitCode { get; init; }
public string CleanedOutput { get; init; } = string.Empty;
// Optional: store raw bytes (Base64) for replay fidelity — omit in v1
}
```

`CommandHistoryEntry` is persisted in the same JSON store as snips, under a
top-level `"history"` array. Use the same atomic write pattern (write to temp
file, rename).

---

## New abstractions (Snipdeck.Core/Abstractions/)

```csharp
// Runs a command, streams output chunks, returns exit code on completion.
// Implementations must not reference WinUI types.
public interface ICommandRunner
{
IAsyncEnumerable<byte[]> RunAsync(
string command,
CancellationToken cancellationToken = default);

// Exit code available after the async enumerable completes.
int LastExitCode { get; }
}

public interface ICommandHistoryStore
{
Task<IReadOnlyList<CommandHistoryEntry>> GetAllAsync();
Task AddAsync(CommandHistoryEntry entry);
Task DeleteAsync(Guid id);
Task ClearAllAsync();
}
```

---

## New service implementations

### PipeCommandRunner → `Snipdeck.Core/Services/`
- Uses `System.Diagnostics.Process` with `RedirectStandardOutput = true` and
`RedirectStandardError = true`.
- Merges stdout and stderr into one stream (write stderr chunks too — the user
needs to see error output in the live view).
- Targets `net10.0` — no Windows dependency. Unit-testable.
- When `Console.IsOutputRedirected` is `true`, Spectre.Console self-demotes;
this path is sufficient for tools that respect that convention.

### PtyCommandRunner → `Snipdeck.App/Services/`
- Uses **Porta.Pty** (`dotnet add Snipdeck.App package Porta.Pty`).
- Wraps Windows ConPTY so the child process sees a real terminal.
`Console.IsOutputRedirected` stays `false`; progress bars and spinners
render fully.
- Lives in `App` because it has a Windows platform dependency.
- Implements `ICommandRunner` — the view model does not know which runner
is in use; DI provides it.

### VtOutputProcessor → `Snipdeck.Core/Engine/`
- Pure static logic. No I/O, no dependencies. The most important test target
for this whole feature.
- Input: raw VT byte string (UTF-8). Output: clean plain text.
- Algorithm:

```
1. Decode bytes to string (UTF-8).
2. Simulate a simple line buffer:
- Split on \r and \n, tracking current-line index and column position.
- \r → reset column to 0 (overwrite mode — spinner frames cancel out).
- \n → advance to next line.
- \x1b[2K → clear the current line buffer.
- \x1b[nA → move cursor up n lines (multi-line progress bar erasure).
- \x1b[nB → move cursor down n lines.
- \x1b[nD → move cursor back n columns.
- Any other \x1b[… sequence → discard (don't emit to buffer).
3. After simulation, strip remaining ANSI colour/style codes via regex:
\x1b\[[0-9;]*[A-Za-z]
4. Trim blank lines left by erased progress bar frames.
5. Return joined lines.
```

- Commit raw VT output from your actual CLIs as test fixtures
(`tests/Snipdeck.Core.Tests/Fixtures/`) and write xUnit tests against them.
This is where a silent bug (mangled command stored in history) hurts most.

### CommandHistoryStore → `Snipdeck.Core/Services/`
- Implements `ICommandHistoryStore`.
- Reads/writes the `"history"` array in the JSON store.
- Same atomic write pattern as the snip store (write temp → rename).

---

## Live view: WebView2 + xterm.js (Snipdeck.App)

NuGet: `Microsoft.Web.WebView2` (add to `Snipdeck.App` if not already present).

Embed an HTML page as a build asset (`Assets/terminal.html`). The page
initialises an xterm.js terminal that fills the container:

```html
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css"/>
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
<style>html,body,#terminal{margin:0;padding:0;background:#0d0d0d;height:100vh}</style>
</head>
<body>
<div id="terminal"></div>
<script>
const term = new Terminal({ theme: { background: '#0d0d0d' }, convertEol: true });
term.open(document.getElementById('terminal'));
window.chrome.webview.addEventListener('message', e => {
const bytes = Uint8Array.from(atob(e.data), c => c.charCodeAt(0));
term.write(bytes);
});
</script>
</body>
</html>
```

Stream PTY bytes to it from C#:

```csharp
// In the view or code-behind that owns the WebView2 instance
private async Task ForwardChunkAsync(byte[] chunk)
{
var b64 = Convert.ToBase64String(chunk);
await _webView.CoreWebView2.ExecuteScriptAsync(
$"window.chrome.webview.postMessage('{b64}')");
// Note: direction is App → WebView2, use PostWebMessageAsString
}
```

Actually use `PostWebMessageAsString` on the C# side and receive via
`window.chrome.webview.addEventListener('message', ...)` on the JS side.
Base64-encode chunks to avoid escaping issues with raw VT bytes crossing
the interop boundary.

Initialise xterm.js after `CoreWebView2InitializationCompleted` fires — not
in the constructor.

---

## Execution flow (view model, Snipdeck.Core/ViewModels/)

```
User clicks "Run" on a Snip
├─ Create CommandHistoryEntry (CliId, SnipId, ResolvedCommand, ExecutedAt = now)
├─ Start stopwatch
├─ foreach byte[] chunk in _commandRunner.RunAsync(resolvedCommand)
│ ├─ Append chunk to in-memory buffer (List<byte>)
│ └─ Raise OutputChunkReceived event → App wires to WebView2 forwarder
└─ On completion
├─ Stop stopwatch → DurationMs
├─ ExitCode = _commandRunner.LastExitCode
├─ CleanedOutput = VtOutputProcessor.Process(buffer)
└─ await _historyStore.AddAsync(entry)
```

The view model raises `OutputChunkReceived` as an event (or an
`IObservable<byte[]>`). The view (App layer) subscribes and forwards to
WebView2. The view model never references WebView2 directly — it stays in
`Core`.

---

## New NuGet packages

| Package | Project |
|---|---|
| `Porta.Pty` | `Snipdeck.App` |
| `Microsoft.Web.WebView2` | `Snipdeck.App` |

---

## What NOT to do

- **Do not put `PtyCommandRunner` in `Core`** — it has a Windows dependency
(ConPTY). It belongs in `App`.
- **Do not write VT bytes directly into `CleanedOutput`** — always run through
`VtOutputProcessor` first.
- **Do not initialise WebView2 in the constructor** — wait for
`CoreWebView2InitializationCompleted`.
- **Do not block the UI thread** while the command runs — `RunAsync` is
`IAsyncEnumerable`; await it on a background context and marshal UI updates
via `IDispatcher`.
- **Do not store raw bytes in history by default** — `CleanedOutput` is the
contract. Raw bytes are optional and deferred to a future version.

---

## Test targets (priority order)

1. `VtOutputProcessor` — unit tests against real CLI output fixtures.
Cover: spinner (CR overwrite), multi-line progress bar (cursor-up + erase),
colour-only output (ANSI strip only), plain output (passthrough).
2. `CommandHistoryStore` — round-trip (add, read back, delete).
3. `PipeCommandRunner` — integration test against a known simple command.
Loading
Loading