diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e395f20..34f4ad0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,15 +31,19 @@ jobs:
- name: Test
run: dotnet test --no-build -c Release --verbosity normal
- # Proves the publish profile in OpenKey.csproj still produces the shipping artifact
- # without anyone pasting flags from a README.
- - name: Verify single-file publish
- run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r win-x64 -o publish-check
+ # Proves the publish profile in each csproj still produces the shipping artifact without
+ # anyone pasting flags from a README.
+ - name: Verify console publish
+ run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r win-x64 -o publish-check/console
- - name: Confirm the exe exists
+ - name: Verify app publish
+ run: dotnet publish src/OpenKey.Gui/OpenKey.Gui.csproj -c Release -r win-x64 -o publish-check/app
+
+ - name: Confirm both binaries exist
shell: pwsh
run: |
- $exe = "publish-check/OpenKey.exe"
- if (-not (Test-Path $exe)) { throw "Expected $exe to exist" }
- $mb = [math]::Round((Get-Item $exe).Length / 1MB, 1)
- Write-Host "OpenKey.exe is $mb MB"
+ foreach ($p in @("publish-check/console/OpenKey.exe", "publish-check/app/OpenKeyApp.exe")) {
+ if (-not (Test-Path $p)) { throw "Expected $p to exist" }
+ $mb = [math]::Round((Get-Item $p).Length / 1MB, 1)
+ Write-Host "$p is $mb MB"
+ }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d9742eb..e776664 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -28,11 +28,17 @@ jobs:
- name: Publish ${{ matrix.rid }}
run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r ${{ matrix.rid }} -o out/${{ matrix.rid }}
- - name: Name the artifact by platform
+ # Both surfaces ship from the same tag: the console for people who live in a terminal,
+ # the windowed app for everyone else. Same engine underneath.
+ - name: Publish the app ${{ matrix.rid }}
+ run: dotnet publish src/OpenKey.Gui/OpenKey.Gui.csproj -c Release -r ${{ matrix.rid }} -o out-app/${{ matrix.rid }}
+
+ - name: Name the artifacts by platform
shell: pwsh
run: |
New-Item -ItemType Directory -Force dist | Out-Null
Copy-Item "out/${{ matrix.rid }}/OpenKey.exe" "dist/OpenKey-${{ matrix.rid }}.exe"
+ Copy-Item "out-app/${{ matrix.rid }}/OpenKeyApp.exe" "dist/OpenKeyApp-${{ matrix.rid }}.exe"
# Code signing removes the SmartScreen "unrecognized app" warning, which is the biggest
# friction point for a product handed over on a USB stick. Disabled until the secrets exist;
@@ -58,9 +64,12 @@ jobs:
- name: Checksum
shell: pwsh
run: |
- $h = (Get-FileHash "dist/OpenKey-${{ matrix.rid }}.exe" -Algorithm SHA256).Hash.ToLower()
- "$h OpenKey-${{ matrix.rid }}.exe" | Out-File -Encoding ascii "dist/${{ matrix.rid }}.sha256"
- Write-Host "$h OpenKey-${{ matrix.rid }}.exe"
+ Remove-Item "dist/*.sha256" -ErrorAction SilentlyContinue
+ Get-ChildItem dist -Filter *.exe | ForEach-Object {
+ $h = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
+ "$h $($_.Name)" | Out-File -Encoding ascii -Append "dist/${{ matrix.rid }}.sha256"
+ Write-Host "$h $($_.Name)"
+ }
- uses: actions/upload-artifact@v4
with:
diff --git a/BACKLOG.md b/BACKLOG.md
index 24fa30f..54f5c6d 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -65,6 +65,15 @@ Also fixed en route: `Microsoft.ML.Tokenizers` 2.0.0 pulls in `Microsoft.Bcl.Mem
carries a known high-severity advisory (GHSA-73j8-2gch-69rq). NuGet audit failed the build; pinned
forward to 10.0.10.
+### Phase 2 — Avalonia GUI — 2026-08-03
+
+Shipped to the roadmap's bar: parity with the console feature set plus mouse selection, copy
+buttons on code blocks, and syntax highlighting. `OpenKeyApp.exe` builds AOT and ships from the
+same tag as the console.
+
+`OpenKey.Windows` was extracted so both hosts share DPAPI, app paths, OAuth and the tokenizer.
+`OpenKey.Core` still has zero package references.
+
### v0.1.0 — 2026-05-28
First release. See [`CHANGELOG.md`](CHANGELOG.md#010--2026-05-28).
@@ -75,8 +84,16 @@ First release. See [`CHANGELOG.md`](CHANGELOG.md#010--2026-05-28).
Ordered by user value within tier. Lowest tier wins.
-Tier 2 and Tier 3 are complete — see **Done** above. What remains is Tier 4, which is Phase 5 work
-and a step change in scope rather than more polish.
+Tier 2, Tier 3 and Tier 5's Phase 2 (the GUI) are complete — see **Done** above. What remains is
+Tier 4, which is Phase 5 work and a step change in scope rather than more polish.
+
+Smaller GUI follow-ups, none blocking:
+
+- Inline bold/italic inside paragraphs. The block model supports it; the renderer currently
+ flattens inline formatting to plain text.
+- Window size and position persistence. Deliberately skipped: `config.json`'s shape is a contract
+ surface documented in `docs/05`, and window bounds do not belong in it without a decision.
+- Per-message copy buttons, in addition to the toolbar's copy-last and the per-code-block copy.
### Tier 4 — providers
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31e82d0..719b362 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+## [0.3.0] — 2026-08-03
+
+### Added
+
+- **A windowed app.** `OpenKeyApp.exe` ships alongside the console from the same release: the same
+ chat, the same models, the same saved conversation, in a normal window. Code blocks are syntax
+ highlighted and have their own copy button, text is selectable with the mouse, and there are
+ buttons for new chat, retry, copy, export, model choice, theme and about.
+- Four colour themes in both surfaces — default, dark, light and mono — remembered between runs and
+ shared between the console and the app.
+- Keyboard shortcuts in the app: Enter sends, Shift+Enter adds a line, Esc stops a reply, Ctrl+L
+ clears the chat.
+- **Clear can be undone.** Clearing a chat offers an Undo for as long as you haven't sent anything
+ new, so a misclick doesn't cost you the conversation.
+
+### Changed
+
+- The app's "New chat" button is now "Clear". It never started a new conversation alongside the old
+ one — it ended the only one there is — and the old label implied otherwise.
+- Theme, About and Erase everything moved into a settings menu, leaving the toolbar for things that
+ act on the conversation. Erase now sits alone at the bottom of that menu, away from Export.
+- The model picker has an **Automatic** option again, so you can hand the choice back to OpenKey
+ after picking a specific model.
+
## [0.2.1] — 2026-08-03
Two defects that shipped in 0.2.0, both found by using the app rather than reading it — plus a
@@ -57,6 +81,13 @@ much smaller, faster binary.
### Fixed
+- **A mistyped or revoked key was accepted and saved.** OpenKey checked keys against an endpoint
+ that does not require one, so any text passed — and then every message failed, with advice that
+ led back to the same place. Keys are now genuinely verified before being saved.
+- **Piped or scripted input crashed the app.** Anything that needed a menu — choosing a model,
+ confirming an erase, first-run setup — closed OpenKey with an error when input didn't come from
+ a keyboard. Those now fall back to typing a number or a word.
+- Choosing a model said the choice lasted "until you close OpenKey"; it is remembered.
- Links whose address contained a bracket lost their target when displayed.
- A message that kept failing could retry for several minutes; it is now bounded, and a reply
that is genuinely arriving is never cut off.
@@ -122,7 +153,8 @@ much smaller, faster binary.
- Commands: `/about`, `/models`, `/model`, `/cls`, `/help`, `/reset`, `/quit`.
- Single self-contained `.exe` that runs from a USB stick with nothing installed.
-[Unreleased]: https://github.com/corecompiled/OpenKey/compare/v0.2.1...HEAD
+[Unreleased]: https://github.com/corecompiled/OpenKey/compare/v0.3.0...HEAD
+[0.3.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.3.0
[0.2.1]: https://github.com/corecompiled/OpenKey/releases/tag/v0.2.1
[0.2.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.2.0
[0.1.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.1.0
diff --git a/Directory.Build.props b/Directory.Build.props
index 1f76133..b890730 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,7 +8,7 @@
truelatestRecommended
- 0.2.1
+ 0.3.0en-US
diff --git a/OpenKey.slnx b/OpenKey.slnx
index 5d0abba..fb2104b 100644
--- a/OpenKey.slnx
+++ b/OpenKey.slnx
@@ -1,12 +1,15 @@
+
+
+
diff --git a/README.md b/README.md
index 7235bb8..222fa3a 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,15 @@ Patron ❯
## Get it
-Download `OpenKey.exe` from [Releases](https://github.com/corecompiled/OpenKey/releases) and
-double-click it. One 11 MB file, nothing installed, runs from a USB stick.
+Two ways to use it, same chat and same saved conversation underneath:
+
+| | |
+|---|---|
+| **`OpenKeyApp.exe`** | A normal window. Start here if you're not sure. |
+| **`OpenKey.exe`** | The terminal version, if that's where you live. |
+
+Download either from [Releases](https://github.com/corecompiled/OpenKey/releases) and double-click.
+Around 11–30 MB, nothing installed, runs from a USB stick.
Or via [Scoop](https://scoop.sh), which also avoids the SmartScreen prompt:
diff --git a/docs/07-roadmap.md b/docs/07-roadmap.md
index 1af724e..cdb9cf3 100644
--- a/docs/07-roadmap.md
+++ b/docs/07-roadmap.md
@@ -68,21 +68,29 @@ Features that touch storage or DI but stay backward-compatible.
- Why not WinUI 3: more setup friction (project templates change frequently in 2025/2026), packaging awkwardness.
- Why not Electron/web tech: defeats the lightweight portable-exe ethos.
-**Project layout addition:**
+**Project layout as built:**
```
src\OpenKey.Gui\
- OpenKey.Gui.csproj (Sdk: Microsoft.NET.Sdk; AvaloniaUseCompiledBindingsByDefault=true)
- App.axaml Avalonia app shell
- MainWindow.axaml chat surface
- ViewModels\ChatViewModel.cs binds to OpenKey.Core.ChatEngine
+ App.axaml / GuiTheme.cs app shell and palettes
+ Views\MainWindow.axaml chat surface
+ Views\CodeBlockView.axaml highlighted code with a copy button
+ Views\ConfirmWindow.axaml destructive confirms and About
+ ViewModels\ MainWindowViewModel, MessageViewModel,
+ MarkdownBlock, SyntaxHighlighter
+src\OpenKey.Windows\ DPAPI, app paths, OAuth, tokenizer —
+ shared by both hosts
```
+`OpenKey.Windows` was extracted when the GUI needed the same platform pieces the console already had. `OpenKey.Core` still has zero package references and still targets `net10.0` rather than `net10.0-windows`.
+
**Key reuse principle:** `OpenKey.Core` and `OpenKey.Providers.OpenRouter` are referenced unchanged. `ChatEngine` is the boundary — the GUI binds an `IAsyncEnumerable` to a `TextBox`/`ItemsControl` exactly as the console renders it.
-**New publish target:** Avalonia can also publish single-file self-contained. Same flags as Phase 1's `dotnet publish` line, swap project path to `src/OpenKey.Gui/OpenKey.Gui.csproj`. Output size: ~50–80 MB.
+**Publish target:** `src/OpenKey.Gui/OpenKey.Gui.csproj`, NativeAOT like the console, shipping as `OpenKeyApp.exe` beside `OpenKey.exe` from the same tag.
+
+**Shipped.** Parity with the console feature set — new chat, retry, copy, export, model picker, theme, about, erase — plus the three GUI-specific items: mouse selection via `SelectableTextBlock`, a copy button on every code block, and syntax highlighting.
-**Phase 2 ships when:** GUI feature-parity with Phase 1.2 console + GUI-specific QoL (mouse selection, copy code blocks, syntax highlighting via Avalonia.HtmlRenderer or Markdig + AvaloniaEdit).
+Highlighting is a small in-house tokenizer rather than AvaloniaEdit or a TextMate grammar engine. Those are built for *editing* — buffers, folding, undo, grammar files — and none of that applies to text displayed once and never modified. A keyword in the wrong colour costs nothing; the dependency costs megabytes and an AOT risk. Recorded in [`architecture/08-decisions.md`](architecture/08-decisions.md).
## Phase 3 — Tool use / function calling
diff --git a/docs/architecture/08-decisions.md b/docs/architecture/08-decisions.md
index 73ae6ab..8b11d7d 100644
--- a/docs/architecture/08-decisions.md
+++ b/docs/architecture/08-decisions.md
@@ -155,6 +155,43 @@ as commands.
---
+## A hand-written syntax highlighter, not AvaloniaEdit
+
+**Status:** decided
+
+The GUI colours code blocks in replies. The obvious options were AvaloniaEdit or a TextMate grammar
+engine, and both were rejected: they are built for *editing* — buffers, folding, undo, grammar
+files, incremental re-lex — and a chat reply is displayed once and never modified.
+
+The asymmetry decides it. Getting a keyword colour wrong is invisible to most readers and harmless
+to all of them. Taking the dependency costs megabytes in a binary whose whole pitch is that it is
+11 MB, plus an AOT compatibility risk in a build that is now fully native.
+
+`SyntaxHighlighter` is one regex per dialect family (C-style, hash-comment, SQL) and three keyword
+sets. Comments and strings match first, because a keyword inside a comment is not a keyword. An
+unknown language renders plain rather than guessing.
+
+The invariant that actually matters is tested: concatenating the tokens must reproduce the source
+exactly. Highlighting is a view over the text, and a dropped character would silently corrupt code
+the user is about to copy.
+
+## A second host, not a second implementation
+
+**Status:** decided
+
+`OpenKey.Gui` contains view models and views and nothing else — no chat logic, no rotation, no
+persistence, no provider code. Composition in its `Program.cs` is line-for-line the console's.
+
+This was the test of whether the layering was real. It held: the GUI needed no change to
+`OpenKey.Core` at all. The one thing it did force was extracting `OpenKey.Windows` — DPAPI, app
+paths, OAuth and the tokenizer had been sitting inside the console executable where a second host
+could not reach them.
+
+The GUI keeps the console's behavioural rules rather than inventing its own: block-level streaming,
+`IsAttemptRestart` clearing an abandoned reply, errors that name a next step, destructive confirms
+that default to Cancel. Themes are shared through `config.json`, so switching in one host and
+opening the other keeps the choice — the *setting* is shared, the *rendering* is per-host.
+
## The multi-provider seam is designed but unproven
**Status:** acknowledged
diff --git a/src/OpenKey.Core/Engine/ChatEngine.cs b/src/OpenKey.Core/Engine/ChatEngine.cs
index 08b64b2..97a1e5d 100644
--- a/src/OpenKey.Core/Engine/ChatEngine.cs
+++ b/src/OpenKey.Core/Engine/ChatEngine.cs
@@ -92,6 +92,25 @@ public Task NewSessionAsync(CancellationToken ct)
return Task.CompletedTask;
}
+ ///
+ /// Puts a previous conversation back and re-persists it. Exists so a host can offer undo after
+ /// clearing — destroying someone's conversation should be reversible, and a confirmation
+ /// dialog interrupts everyone to protect against a rare mistake, where undo costs nothing
+ /// until it is needed.
+ ///
+ public async Task RestoreTurnsAsync(IReadOnlyList turns, CancellationToken ct)
+ {
+ _turns.Clear();
+ _turns.AddRange(turns);
+
+ if (_turns.Count == 0 || _turns[0].Role != ChatMessage.SystemRole)
+ _turns.Insert(0, new ChatMessage(ChatMessage.SystemRole, DefaultSystemPrompt));
+
+ await _sessions.SaveAsync(
+ new SessionSnapshot(ActiveModel?.Id ?? string.Empty, DateTimeOffset.UtcNow, _turns.ToArray()),
+ ct);
+ }
+
public async IAsyncEnumerable SendAsync(
string userText,
[EnumeratorCancellation] CancellationToken ct)
diff --git a/src/OpenKey.Gui/App.axaml b/src/OpenKey.Gui/App.axaml
new file mode 100644
index 0000000..12929db
--- /dev/null
+++ b/src/OpenKey.Gui/App.axaml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenKey.Gui/App.axaml.cs b/src/OpenKey.Gui/App.axaml.cs
new file mode 100644
index 0000000..89be926
--- /dev/null
+++ b/src/OpenKey.Gui/App.axaml.cs
@@ -0,0 +1,35 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Microsoft.Extensions.DependencyInjection;
+using OpenKey.Gui.ViewModels;
+using OpenKey.Gui.Views;
+
+namespace OpenKey.Gui;
+
+public partial class App : Application
+{
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var vm = Program.Services.GetRequiredService();
+
+ // Before the window exists, so it never renders in one palette and repaints into
+ // another. The setting is shared with the console via config.json.
+ GuiTheme.Apply(this, vm.Theme);
+ vm.ThemeChanged += name => GuiTheme.Apply(this, name);
+
+ desktop.MainWindow = new MainWindow { DataContext = vm };
+
+ // Start-up work (loading the key, restoring the conversation, fetching models) happens
+ // after the window is up, so the user sees the app immediately rather than a delay
+ // followed by a window.
+ desktop.MainWindow.Opened += async (_, _) => await vm.InitializeAsync();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/src/OpenKey.Gui/Converters/StatusBrushConverter.cs b/src/OpenKey.Gui/Converters/StatusBrushConverter.cs
new file mode 100644
index 0000000..3dacfe9
--- /dev/null
+++ b/src/OpenKey.Gui/Converters/StatusBrushConverter.cs
@@ -0,0 +1,30 @@
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+using OpenKey.Gui.ViewModels;
+
+namespace OpenKey.Gui.Converters;
+
+///
+/// Maps a status severity to its accent colour. Kept in one place for the same reason the console
+/// keeps every colour in Theme: a brush chosen at a call site is how a palette drifts.
+///
+public sealed class StatusBrushConverter : IValueConverter
+{
+ private static readonly SolidColorBrush Info = new(Color.Parse("#22D3EE"));
+ private static readonly SolidColorBrush Ok = new(Color.Parse("#4ADE80"));
+ private static readonly SolidColorBrush Warn = new(Color.Parse("#FBBF24"));
+ private static readonly SolidColorBrush Danger = new(Color.Parse("#F87171"));
+
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ (value as StatusKind?) switch
+ {
+ StatusKind.Ok => Ok,
+ StatusKind.Warn => Warn,
+ StatusKind.Danger => Danger,
+ _ => Info,
+ };
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
diff --git a/src/OpenKey.Gui/GuiTheme.cs b/src/OpenKey.Gui/GuiTheme.cs
new file mode 100644
index 0000000..124142b
--- /dev/null
+++ b/src/OpenKey.Gui/GuiTheme.cs
@@ -0,0 +1,109 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Styling;
+
+namespace OpenKey.Gui;
+
+///
+/// Applies a palette to the running application.
+///
+/// Same governing rule as the console: one accent, one neutral, three signals. Colour carries
+/// meaning, never decoration, and no state is signalled by colour alone — which is what makes the
+/// mono palette a legitimate option rather than a novelty.
+///
+///
+/// Themes are shared with the console through config.json, so switching in one and opening
+/// the other keeps your choice. Rendering is per-host; the setting is not.
+///
+///
+internal static class GuiTheme
+{
+ public const string Default = "default";
+ public const string Dark = "dark";
+ public const string Light = "light";
+ public const string Mono = "mono";
+
+ public static readonly string[] All = { Default, Dark, Light, Mono };
+
+ public static string Current { get; private set; } = Default;
+
+ public static bool IsKnown(string? name) =>
+ name is not null && All.Contains(name.Trim().ToLowerInvariant(), StringComparer.OrdinalIgnoreCase);
+
+ public static void Apply(Application app, string? name)
+ {
+ var theme = (name ?? Default).Trim().ToLowerInvariant();
+ if (!IsKnown(theme)) theme = Default;
+ Current = theme;
+
+ app.RequestedThemeVariant = theme == Light ? ThemeVariant.Light : ThemeVariant.Dark;
+
+ var r = app.Resources;
+
+ switch (theme)
+ {
+ case Light:
+ Set(r, "Surface", "#F8FAFC");
+ Set(r, "SurfaceRaised", "#EEF2F7");
+ Set(r, "CodeSurface", "#F1F5F9");
+ Set(r, "Line", "#CBD5E1");
+ Set(r, "Body", "#0F172A");
+ Set(r, "Muted", "#64748B");
+ Set(r, "Brand", "#0E7490");
+ Set(r, "Ok", "#15803D");
+ Set(r, "Warn", "#B45309");
+ Set(r, "Danger", "#B91C1C");
+ Set(r, "OnBrand", "#F8FAFC");
+ Set(r, "CodeKeyword", "#7C3AED");
+ Set(r, "CodeString", "#047857");
+ Set(r, "CodeComment", "#94A3B8");
+ Set(r, "CodeNumber", "#B45309");
+ Set(r, "CodeType", "#0E7490");
+ break;
+
+ case Mono:
+ // No hue at all. Everything must stay legible, which is the standing test that
+ // meaning never depended on colour in the first place.
+ Set(r, "Surface", "#101010");
+ Set(r, "SurfaceRaised", "#1B1B1B");
+ Set(r, "CodeSurface", "#161616");
+ Set(r, "Line", "#3A3A3A");
+ Set(r, "Body", "#E8E8E8");
+ Set(r, "Muted", "#9A9A9A");
+ Set(r, "Brand", "#E8E8E8");
+ Set(r, "Ok", "#E8E8E8");
+ Set(r, "Warn", "#C8C8C8");
+ Set(r, "Danger", "#FFFFFF");
+ Set(r, "OnBrand", "#101010");
+ Set(r, "CodeKeyword", "#FFFFFF");
+ Set(r, "CodeString", "#C8C8C8");
+ Set(r, "CodeComment", "#7A7A7A");
+ Set(r, "CodeNumber", "#C8C8C8");
+ Set(r, "CodeType", "#E8E8E8");
+ break;
+
+ default: // default and dark are the same palette; "dark" exists so the name works
+ Set(r, "Surface", "#0F172A");
+ Set(r, "SurfaceRaised", "#1E293B");
+ Set(r, "CodeSurface", "#0B1220");
+ Set(r, "Line", "#334155");
+ Set(r, "Body", "#E2E8F0");
+ Set(r, "Muted", "#94A3B8");
+ Set(r, "Brand", "#22D3EE");
+ Set(r, "Ok", "#4ADE80");
+ Set(r, "Warn", "#FBBF24");
+ Set(r, "Danger", "#F87171");
+ Set(r, "OnBrand", "#04121A");
+ Set(r, "CodeKeyword", "#C4B5FD");
+ Set(r, "CodeString", "#86EFAC");
+ Set(r, "CodeComment", "#64748B");
+ Set(r, "CodeNumber", "#FCD34D");
+ Set(r, "CodeType", "#7DD3FC");
+ break;
+ }
+ }
+
+ private static void Set(IResourceDictionary resources, string key, string hex) =>
+ resources[key] = new SolidColorBrush(Color.Parse(hex));
+}
diff --git a/src/OpenKey.Gui/OpenKey.Gui.csproj b/src/OpenKey.Gui/OpenKey.Gui.csproj
new file mode 100644
index 0000000..800abc9
--- /dev/null
+++ b/src/OpenKey.Gui/OpenKey.Gui.csproj
@@ -0,0 +1,40 @@
+
+
+ WinExe
+ net10.0-windows
+ OpenKey.Gui
+ OpenKeyApp
+ win-x64;win-arm64
+ false
+ app.manifest
+ true
+
+
+ true
+ true
+
+
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenKey.Gui/Program.cs b/src/OpenKey.Gui/Program.cs
new file mode 100644
index 0000000..e024b44
--- /dev/null
+++ b/src/OpenKey.Gui/Program.cs
@@ -0,0 +1,67 @@
+using Avalonia;
+using Microsoft.Extensions.DependencyInjection;
+using OpenKey.Core.AppPaths;
+using OpenKey.Core.Engine;
+using OpenKey.Core.Providers;
+using OpenKey.Core.Storage;
+using OpenKey.Gui.ViewModels;
+using OpenKey.Providers.OpenRouter;
+using OpenKey.Windows;
+
+namespace OpenKey.Gui;
+
+internal static class Program
+{
+ ///
+ /// Composition is identical to the console host's, and deliberately so: the GUI is a second
+ /// host over the same engine, not a second implementation. Everything below the view models
+ /// is shared, which is what makes this project small.
+ ///
+ public static IServiceProvider Services { get; private set; } = default!;
+
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ var services = new ServiceCollection();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Infinite on purpose — see the console host's Program.cs. HttpClient.Timeout bounds the
+ // whole response including the body, which silently aborts long streamed replies; the
+ // provider applies per-read deadlines instead.
+ services.AddSingleton(_ => new HttpClient { Timeout = Timeout.InfiniteTimeSpan });
+
+ services.AddSingleton(sp =>
+ new OpenRouterProvider(
+ sp.GetRequiredService(),
+ sp.GetRequiredService().Load));
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ var provider = services.BuildServiceProvider();
+ Services = provider;
+
+ try
+ {
+ return BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
+ finally
+ {
+ provider.Dispose();
+ }
+ }
+
+ // Referenced by name by the Avalonia designer tooling; keep the signature.
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..916d7e2
--- /dev/null
+++ b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,562 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Reflection;
+using Avalonia.Threading;
+using OpenKey.Core.AppPaths;
+using OpenKey.Core.Engine;
+using OpenKey.Core.Providers;
+using OpenKey.Core.Storage;
+using OpenKey.Providers.OpenRouter;
+using OpenKey.Windows.OAuth;
+
+namespace OpenKey.Gui.ViewModels;
+
+public sealed class MainWindowViewModel : ObservableObject
+{
+ private readonly ChatEngine _engine;
+ private readonly IKeyStore _keys;
+ private readonly IModelCatalog _catalog;
+ private readonly IRotationPolicy _rotation;
+ private readonly IConfigStore _config;
+ private readonly IAppPaths _paths;
+ private readonly HttpClient _http;
+
+ private CancellationTokenSource? _turnCts;
+ private string _draft = string.Empty;
+ private bool _isBusy;
+ private bool _needsKey;
+ private string? _status;
+ private StatusKind _statusKind;
+ private string _keyInput = string.Empty;
+ private bool _isSigningIn;
+
+ public MainWindowViewModel(
+ ChatEngine engine,
+ IKeyStore keys,
+ IModelCatalog catalog,
+ IRotationPolicy rotation,
+ IConfigStore config,
+ IAppPaths paths,
+ HttpClient http)
+ {
+ _engine = engine;
+ _keys = keys;
+ _catalog = catalog;
+ _rotation = rotation;
+ _config = config;
+ _paths = paths;
+ _http = http;
+
+ Messages.CollectionChanged += (_, _) => Raise(nameof(IsConversationEmpty));
+
+ StopCommand = new RelayCommand(Stop);
+ ClearCommand = new RelayCommand(() => _ = ClearConversationAsync());
+ }
+
+ public ObservableCollection Messages { get; } = new();
+
+ /// Drives the empty-state prompt. A bare Count cannot bind to IsVisible.
+ public bool IsConversationEmpty => Messages.Count == 0;
+
+ public ObservableCollection Models { get; } = new();
+
+ private ModelChoice? _selectedModel;
+
+ ///
+ /// Two-way bound so the picker reflects the saved choice on launch instead of showing a
+ /// placeholder while a model is actually pinned.
+ ///
+ public ModelChoice? SelectedModel
+ {
+ get => _selectedModel;
+ set
+ {
+ if (!Set(ref _selectedModel, value) || value is null) return;
+ PinModel(value.Model);
+ }
+ }
+
+ public string Version { get; } =
+ (typeof(MainWindowViewModel).Assembly
+ .GetCustomAttribute()?.InformationalVersion
+ ?? "dev") is var v && v.IndexOf('+', StringComparison.Ordinal) > 0
+ ? v[..v.IndexOf('+', StringComparison.Ordinal)]
+ : v;
+
+ public string DataDirectory => _paths.RootDir;
+
+ public string Draft
+ {
+ get => _draft;
+ set { if (Set(ref _draft, value)) Raise(nameof(CanSend)); }
+ }
+
+ /// True while a reply is in flight. Drives the send/stop button and input state.
+ public bool IsBusy
+ {
+ get => _isBusy;
+ private set
+ {
+ if (!Set(ref _isBusy, value)) return;
+ Raise(nameof(CanSend));
+ Raise(nameof(IsIdle));
+ }
+ }
+
+ public bool IsIdle => !_isBusy;
+
+ public bool CanSend => !_isBusy && !string.IsNullOrWhiteSpace(_draft);
+
+ /// Shows the sign-in panel instead of the chat view.
+ public bool NeedsKey
+ {
+ get => _needsKey;
+ private set { if (Set(ref _needsKey, value)) Raise(nameof(IsReady)); }
+ }
+
+ public bool IsReady => !_needsKey;
+
+ public string KeyInput
+ {
+ get => _keyInput;
+ set => Set(ref _keyInput, value);
+ }
+
+ public bool IsSigningIn
+ {
+ get => _isSigningIn;
+ private set => Set(ref _isSigningIn, value);
+ }
+
+ public string? Status
+ {
+ get => _status;
+ private set { if (Set(ref _status, value)) Raise(nameof(HasStatus)); }
+ }
+
+ public bool HasStatus => !string.IsNullOrEmpty(_status);
+
+ public StatusKind StatusKind
+ {
+ get => _statusKind;
+ private set => Set(ref _statusKind, value);
+ }
+
+ public string ActiveModel => _engine.ActiveModel?.Id ?? "Choosing automatically";
+
+ public async Task InitializeAsync()
+ {
+ if (!_keys.HasKey() || string.IsNullOrEmpty(_keys.Load()))
+ {
+ NeedsKey = true;
+ return;
+ }
+
+ NeedsKey = false;
+ await _engine.ResumeAsync(CancellationToken.None);
+
+ foreach (var turn in _engine.Turns.Where(t => t.Role != ChatMessage.SystemRole))
+ {
+ Messages.Add(new MessageViewModel(
+ turn.Role == ChatMessage.UserRole ? Speaker.You : Speaker.Assistant,
+ turn.Content));
+ }
+
+ await LoadModelsAsync();
+ }
+
+ public async Task LoadModelsAsync()
+ {
+ try
+ {
+ var models = await _catalog.GetFreeModelsAsync(CancellationToken.None);
+
+ Models.Clear();
+ Models.Add(ModelChoice.Automatic);
+ foreach (var m in models) Models.Add(ModelChoice.For(m));
+
+ // Reflect what is actually saved. Assigning the backing field directly avoids the
+ // setter re-pinning the value we just read.
+ var pinned = _engine.PreferredModelId;
+ _selectedModel = pinned is null
+ ? ModelChoice.Automatic
+ : Models.FirstOrDefault(c => c.Model?.Id == pinned) ?? ModelChoice.Automatic;
+ Raise(nameof(SelectedModel));
+ }
+ catch (ChatException ex)
+ {
+ Show(StatusKind.Warn, Friendly(ex));
+ }
+ }
+
+ // ---- sign in -------------------------------------------------------------------------
+
+ public async Task SignInWithBrowserAsync()
+ {
+ IsSigningIn = true;
+ Show(StatusKind.Info, "Opening your browser to sign in to OpenRouter…");
+
+ try
+ {
+ var oauth = new OpenRouterOAuth(_http);
+ var key = await oauth.AcquireKeyAsync(_ => { }, CancellationToken.None);
+ await ValidateAndSaveAsync(key);
+ }
+ catch (OAuthPortInUseException)
+ {
+ Show(StatusKind.Warn,
+ "OpenKey needs a local port for a moment to receive the sign-in, and they're all in use. "
+ + "Paste a key below instead, or close the other app and try again.");
+ }
+ catch (ChatException ex)
+ {
+ Show(StatusKind.Warn, Friendly(ex));
+ }
+ catch (OperationCanceledException)
+ {
+ Show(StatusKind.Info, "Sign-in cancelled.");
+ }
+ finally
+ {
+ IsSigningIn = false;
+ }
+ }
+
+ public async Task UseTypedKeyAsync()
+ {
+ if (string.IsNullOrWhiteSpace(KeyInput))
+ {
+ Show(StatusKind.Warn, "Paste a key to continue.");
+ return;
+ }
+
+ IsSigningIn = true;
+ try
+ {
+ await ValidateAndSaveAsync(KeyInput.Trim());
+ }
+ finally
+ {
+ IsSigningIn = false;
+ }
+ }
+
+ private async Task ValidateAndSaveAsync(string key)
+ {
+ Show(StatusKind.Info, "Checking your key…");
+ try
+ {
+ var probe = new OpenRouterProvider(_http, () => key);
+
+ // Auth check first: /models is public and answers 200 for anyone, so on its own it
+ // would accept any string as a valid key.
+ await probe.ValidateKeyAsync(CancellationToken.None);
+ var models = await probe.ListModelsAsync(CancellationToken.None);
+ if (models.Count == 0)
+ {
+ Show(StatusKind.Warn, "The key worked, but OpenRouter returned no models. This is usually temporary.");
+ return;
+ }
+
+ _keys.Save(key);
+ KeyInput = string.Empty;
+ Show(StatusKind.Ok, "Key saved. You're ready to chat.");
+ NeedsKey = false;
+ await InitializeAsync();
+ }
+ catch (ChatException ex)
+ {
+ Show(StatusKind.Warn, Friendly(ex));
+ }
+ }
+
+ // ---- chatting ------------------------------------------------------------------------
+
+ public async Task SendAsync()
+ {
+ if (!CanSend) return;
+
+ var text = Draft.TrimEnd();
+ Draft = string.Empty;
+ Status = null;
+
+ if (_clearedTurns is not null)
+ {
+ _clearedTurns = null;
+ _clearedMessages = null;
+ Raise(nameof(CanUndoClear));
+ }
+
+ Messages.Add(new MessageViewModel(Speaker.You, text));
+
+ var reply = new MessageViewModel(Speaker.Assistant) { IsStreaming = true };
+ Messages.Add(reply);
+
+ var cts = new CancellationTokenSource();
+ Volatile.Write(ref _turnCts, cts);
+ IsBusy = true;
+
+ var started = Stopwatch.StartNew();
+ var rotations = 0;
+ void CountRotation(string _) => rotations++;
+ _engine.OnRotation += CountRotation;
+
+ try
+ {
+ await foreach (var chunk in _engine.SendAsync(text, cts.Token))
+ {
+ if (chunk.IsAttemptRestart)
+ {
+ // The engine abandoned that attempt; everything shown belongs to it.
+ rotations++;
+ await Dispatcher.UIThread.InvokeAsync(reply.Reset);
+ continue;
+ }
+
+ if (!string.IsNullOrEmpty(chunk.DeltaText))
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ reply.Append(chunk.DeltaText);
+ reply.ModelId = _engine.ActiveModel?.Id;
+ reply.Elapsed = started.Elapsed;
+ });
+ }
+
+ if (chunk.IsFinal) break;
+ }
+
+ reply.Elapsed = started.Elapsed;
+ reply.ModelId = _engine.ActiveModel?.Id;
+
+ if (!reply.HasText)
+ Show(StatusKind.Warn, "The model accepted the message but returned nothing. Try sending it again.");
+ else if (rotations > 0)
+ Show(StatusKind.Info, $"Moved past {rotations} busy model{(rotations == 1 ? "" : "s")}.");
+ }
+ catch (OperationCanceledException) when (cts.IsCancellationRequested)
+ {
+ if (!reply.HasText) Messages.Remove(reply);
+ Show(StatusKind.Info, "Stopped.");
+ }
+ catch (ChatException ex)
+ {
+ if (!reply.HasText) Messages.Remove(reply);
+ Show(StatusKind.Danger, Friendly(ex));
+ }
+ finally
+ {
+ _engine.OnRotation -= CountRotation;
+ reply.Complete();
+ Volatile.Write(ref _turnCts, null);
+ cts.Dispose();
+ IsBusy = false;
+ Raise(nameof(ActiveModel));
+ }
+ }
+
+ public void Stop() => Volatile.Read(ref _turnCts)?.Cancel();
+
+ // Commands exist only so the window's KeyBindings have something to bind to; every button
+ // calls the methods directly.
+ public System.Windows.Input.ICommand StopCommand { get; }
+
+ public System.Windows.Input.ICommand ClearCommand { get; }
+
+ /// Resends the last message. Goes through SendAsync so a retry takes the same path.
+ public async Task RetryAsync()
+ {
+ if (IsBusy) return;
+
+ if (_engine.LastUserMessage is not { } last)
+ {
+ Show(StatusKind.Info, "Nothing to retry yet — send a message first.");
+ return;
+ }
+
+ Draft = last;
+ await SendAsync();
+ }
+
+ public string? LastReply =>
+ Messages.LastOrDefault(m => m.Speaker == Speaker.Assistant && m.HasText)?.Text;
+
+ ///
+ /// Renders the conversation as markdown, matching the console's /export. Returns null
+ /// when there is nothing to write.
+ ///
+ public string? BuildExport()
+ {
+ var turns = Messages.Where(m => m.HasText).ToList();
+ if (turns.Count == 0) return null;
+
+ var culture = System.Globalization.CultureInfo.InvariantCulture;
+ var sb = new System.Text.StringBuilder();
+
+ sb.AppendLine("# OpenKey conversation").AppendLine();
+ sb.Append(culture, $"Exported {DateTimeOffset.Now:yyyy-MM-dd HH:mm}").AppendLine();
+ if (_engine.ActiveModel is { } m) sb.Append(culture, $"Model: {m.Id}").AppendLine();
+ sb.AppendLine();
+
+ foreach (var turn in turns)
+ {
+ var who = turn.IsFromUser ? "You" : "OpenKey AI";
+ sb.Append(culture, $"## {who}").AppendLine().AppendLine();
+ sb.AppendLine(turn.Text.TrimEnd()).AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ public static string SuggestedExportName =>
+ $"OpenKey-chat-{DateTimeOffset.Now:yyyy-MM-dd-HHmm}.md";
+
+ // ---- theme ---------------------------------------------------------------------------
+
+ /// Raised when the palette changes so the app can repaint. See GuiTheme.
+ public event Action? ThemeChanged;
+
+ public string Theme => _config.Current.Theme;
+
+ public IReadOnlyList Themes { get; } = new[] { "default", "dark", "light", "mono" };
+
+ public void SetTheme(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return;
+ var wanted = name.Trim().ToLowerInvariant();
+ if (wanted == _config.Current.Theme) return;
+
+ _config.Save(_config.Current with { Theme = wanted });
+ Raise(nameof(Theme));
+ ThemeChanged?.Invoke(wanted);
+ }
+
+ // ---- about ---------------------------------------------------------------------------
+
+ public IReadOnlyList<(string Label, string Value)> AboutRows => new[]
+ {
+ ("Version", Version),
+ ("Answering with", _engine.ActiveModel?.Id ?? "Nothing yet — send a message"),
+ ("Model choice", _engine.PreferredModelId ?? "Automatic"),
+ ("Your data", _paths.RootDir),
+ ("Key security", "Encrypted for your Windows account, stored on this PC only"),
+ ("Developer", "Paolo Patron"),
+ };
+
+ public void NotifyStatus(StatusKind kind, string message) => Show(kind, message);
+
+ private IReadOnlyList? _clearedTurns;
+ private MessageViewModel[]? _clearedMessages;
+
+ /// True while a cleared conversation can still be brought back.
+ public bool CanUndoClear => _clearedTurns is not null;
+
+ ///
+ /// Clears the conversation, keeping the key.
+ ///
+ /// This deletes the only conversation OpenKey stores, so it is offered with undo rather than
+ /// behind a confirmation. A dialog interrupts everyone every time to guard against a mistake
+ /// that is rare; undo costs nothing until the moment it is needed, and then it costs one
+ /// click. The button is also labelled "Clear chat" rather than "New chat" — the latter implies
+ /// the old conversation is still somewhere, and it is not.
+ ///
+ ///
+ public async Task ClearConversationAsync()
+ {
+ if (IsBusy) return;
+
+ if (Messages.Count == 0)
+ {
+ Show(StatusKind.Info, "This chat is already empty.");
+ return;
+ }
+
+ _clearedTurns = _engine.Turns.ToArray();
+ _clearedMessages = Messages.ToArray();
+
+ await _engine.NewSessionAsync(CancellationToken.None);
+ Messages.Clear();
+
+ Raise(nameof(CanUndoClear));
+ Show(StatusKind.Ok, "Chat cleared. Your key is untouched.");
+ }
+
+ public async Task UndoClearAsync()
+ {
+ if (_clearedTurns is null || _clearedMessages is null) return;
+
+ await _engine.RestoreTurnsAsync(_clearedTurns, CancellationToken.None);
+
+ Messages.Clear();
+ foreach (var m in _clearedMessages) Messages.Add(m);
+
+ _clearedTurns = null;
+ _clearedMessages = null;
+ Raise(nameof(CanUndoClear));
+ Show(StatusKind.Ok, "Chat restored.");
+ }
+
+ public void PinModel(ModelInfo? model)
+ {
+ _engine.PreferredModelId = model?.Id;
+ Show(StatusKind.Ok, model is null
+ ? "OpenKey will pick the best available model for each message."
+ : $"Now using {model.DisplayName}.");
+ Raise(nameof(ActiveModel));
+ }
+
+ ///
+ /// Erases everything, matching the console's /reset. The view is responsible for
+ /// confirming first — this states plainly what it costs but does not ask.
+ ///
+ public async Task ResetEverythingAsync()
+ {
+ await _engine.NewSessionAsync(CancellationToken.None);
+ _rotation.Clear();
+ _catalog.ClearCache();
+ _keys.Clear();
+ _config.Save(OpenKeyConfig.Default);
+
+ Messages.Clear();
+ Models.Clear();
+ NeedsKey = true;
+ Show(StatusKind.Ok, "Everything was erased. Sign in again to continue.");
+ }
+
+ // ---- status --------------------------------------------------------------------------
+
+ private void Show(StatusKind kind, string message)
+ {
+ StatusKind = kind;
+ Status = message;
+ }
+
+ public void DismissStatus() => Status = null;
+
+ ///
+ /// Same rule as the console: say what happened and what to do next. Raw error-kind names and
+ /// exception text never reach the window.
+ ///
+ private static string Friendly(ChatException ex) => ex.Kind switch
+ {
+ ChatErrorKind.AuthFailure =>
+ "OpenRouter refused the saved key. Use Erase everything to sign in again — that also clears your chat history.",
+ ChatErrorKind.QuotaExhausted =>
+ "This key is out of credit. Wait for your free allowance to renew, or add credit at openrouter.ai.",
+ ChatErrorKind.NetworkDown =>
+ ex.Message + " Check your connection and try again.",
+ ChatErrorKind.TransientRateLimit =>
+ "Every free model is busy right now. Wait a moment and send again, or pick a specific model.",
+ ChatErrorKind.InvalidRequest =>
+ "The model refused this message — it may be too long. Try a shorter one, or pick a different model.",
+ _ => "That didn't go through. Send it again, or pick a different model.",
+ };
+}
+
+public enum StatusKind
+{
+ Info,
+ Ok,
+ Warn,
+ Danger,
+}
diff --git a/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs b/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs
new file mode 100644
index 0000000..12e7502
--- /dev/null
+++ b/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs
@@ -0,0 +1,183 @@
+using System.Text;
+using Markdig;
+using Markdig.Syntax;
+using Markdig.Syntax.Inlines;
+
+namespace OpenKey.Gui.ViewModels;
+
+public enum BlockKind
+{
+ Paragraph,
+ Heading,
+ Code,
+ Quote,
+ ListItem,
+ Rule,
+}
+
+///
+/// A renderable piece of a reply.
+///
+/// Deliberately a flat, plain model rather than a tree of Avalonia controls. The view binds to
+/// these, so parsing stays free of UI types and is testable without a window — the same separation
+/// the console keeps between MarkdownConsoleRenderer and the engine.
+///
+///
+/// Inline formatting is flattened to text in this version. Code fences and structure carry most of
+/// the readability benefit; inline bold and italic can come later without changing this shape.
+///
+///
+public sealed record MarkdownBlock(BlockKind Kind, string Text, string? Language = null, int Level = 0, int Indent = 0)
+{
+ public bool IsCode => Kind == BlockKind.Code;
+ public bool IsRule => Kind == BlockKind.Rule;
+ public bool IsNotCode => Kind != BlockKind.Code && Kind != BlockKind.Rule;
+
+ public double FontSize => Kind switch
+ {
+ BlockKind.Heading when Level <= 1 => 21,
+ BlockKind.Heading when Level == 2 => 18,
+ BlockKind.Heading => 16,
+ _ => 14,
+ };
+
+ public bool IsHeading => Kind == BlockKind.Heading;
+ public bool IsQuote => Kind == BlockKind.Quote;
+
+ ///
+ /// A Thickness, not a double: binding a bare number to Margin indents all four sides, which
+ /// pushes list items down the page as well as across.
+ ///
+ public Avalonia.Thickness LeftMargin =>
+ Kind == BlockKind.ListItem ? new Avalonia.Thickness(16 + (Indent * 16), 0, 0, 0) : default;
+
+ public static IReadOnlyList Parse(string markdown)
+ {
+ var blocks = new List();
+ if (string.IsNullOrWhiteSpace(markdown)) return blocks;
+
+ try
+ {
+ var doc = Markdown.Parse(markdown);
+ foreach (var block in doc) Walk(block, blocks, indent: 0);
+ }
+ catch (Exception)
+ {
+ // Never let a parse failure lose the reply — fall back to showing it verbatim.
+ blocks.Clear();
+ blocks.Add(new MarkdownBlock(BlockKind.Paragraph, markdown));
+ }
+
+ return blocks;
+ }
+
+ private static void Walk(Block block, List into, int indent)
+ {
+ switch (block)
+ {
+ case HeadingBlock h:
+ into.Add(new MarkdownBlock(BlockKind.Heading, Inline(h.Inline), Level: h.Level));
+ break;
+
+ case FencedCodeBlock fenced:
+ into.Add(new MarkdownBlock(
+ BlockKind.Code,
+ fenced.Lines.ToString().TrimEnd('\n', '\r'),
+ Language: string.IsNullOrWhiteSpace(fenced.Info) ? null : fenced.Info.Trim().ToLowerInvariant()));
+ break;
+
+ case CodeBlock code:
+ into.Add(new MarkdownBlock(BlockKind.Code, code.Lines.ToString().TrimEnd('\n', '\r')));
+ break;
+
+ case QuoteBlock quote:
+ foreach (var child in quote)
+ {
+ if (child is LeafBlock lb && lb.Inline is not null)
+ into.Add(new MarkdownBlock(BlockKind.Quote, Inline(lb.Inline)));
+ else
+ Walk(child, into, indent);
+ }
+ break;
+
+ case ListBlock list:
+ {
+ var n = int.TryParse(list.OrderedStart, out var start) ? start : 1;
+ foreach (var item in list)
+ {
+ if (item is not ListItemBlock li) continue;
+ var marker = list.IsOrdered ? $"{n}." : "•";
+ n++;
+
+ var first = true;
+ foreach (var child in li)
+ {
+ if (first && child is ParagraphBlock p)
+ {
+ into.Add(new MarkdownBlock(
+ BlockKind.ListItem, $"{marker} {Inline(p.Inline)}", Indent: indent));
+ first = false;
+ }
+ else
+ {
+ Walk(child, into, indent + 1);
+ }
+ }
+ }
+ break;
+ }
+
+ case ThematicBreakBlock:
+ into.Add(new MarkdownBlock(BlockKind.Rule, string.Empty));
+ break;
+
+ case ParagraphBlock p2:
+ into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(p2.Inline)));
+ break;
+
+ case ContainerBlock container:
+ foreach (var child in container) Walk(child, into, indent);
+ break;
+
+ case LeafBlock leaf when leaf.Inline is not null:
+ into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(leaf.Inline)));
+ break;
+ }
+ }
+
+ private static string Inline(ContainerInline? container)
+ {
+ if (container is null) return string.Empty;
+ var sb = new StringBuilder();
+ Append(sb, container);
+ return sb.ToString();
+ }
+
+ private static void Append(StringBuilder sb, Inline inline)
+ {
+ switch (inline)
+ {
+ case LiteralInline lit:
+ sb.Append(lit.Content.ToString());
+ break;
+ case CodeInline code:
+ sb.Append(code.Content);
+ break;
+ case LineBreakInline:
+ sb.Append('\n');
+ break;
+ case LinkInline link:
+ {
+ foreach (var child in link) Append(sb, child);
+ if (!string.IsNullOrEmpty(link.Url)) sb.Append(" (").Append(link.Url).Append(')');
+ break;
+ }
+ case AutolinkInline auto:
+ sb.Append(auto.Url);
+ break;
+ case ContainerInline container:
+ foreach (var child in container) Append(sb, child);
+ break;
+ }
+ }
+}
diff --git a/src/OpenKey.Gui/ViewModels/MessageViewModel.cs b/src/OpenKey.Gui/ViewModels/MessageViewModel.cs
new file mode 100644
index 0000000..ef48b96
--- /dev/null
+++ b/src/OpenKey.Gui/ViewModels/MessageViewModel.cs
@@ -0,0 +1,115 @@
+using System.Collections.ObjectModel;
+using OpenKey.Core.Providers;
+
+namespace OpenKey.Gui.ViewModels;
+
+public enum Speaker
+{
+ You,
+ Assistant,
+}
+
+///
+/// One turn in the conversation.
+///
+/// Text and rendered blocks are kept side by side on purpose: the raw text is what streams in and
+/// what /copy and export need, while the blocks are what the view draws. Re-parsing markdown
+/// on every delta would be wasteful, so blocks are rebuilt only when a block boundary is reached —
+/// the same rule the console's streaming renderer uses.
+///
+///
+public sealed class MessageViewModel : ObservableObject
+{
+ private string _text = string.Empty;
+ private bool _isStreaming;
+ private string? _modelId;
+ private TimeSpan _elapsed;
+
+ public MessageViewModel(Speaker speaker, string text = "")
+ {
+ Speaker = speaker;
+ _text = text;
+ if (text.Length > 0) RebuildBlocks();
+ }
+
+ public Speaker Speaker { get; }
+
+ public bool IsFromUser => Speaker == Speaker.You;
+
+ public string Header => Speaker == Speaker.You ? Environment.UserName : "OpenKey AI";
+
+ public ObservableCollection Blocks { get; } = new();
+
+ public string Text
+ {
+ get => _text;
+ private set { if (Set(ref _text, value)) Raise(nameof(HasText)); }
+ }
+
+ public bool HasText => _text.Length > 0;
+
+ /// True while deltas are still arriving; drives the caret in the view.
+ public bool IsStreaming
+ {
+ get => _isStreaming;
+ set => Set(ref _isStreaming, value);
+ }
+
+ public string? ModelId
+ {
+ get => _modelId;
+ set { if (Set(ref _modelId, value)) Raise(nameof(Subtitle)); }
+ }
+
+ public TimeSpan Elapsed
+ {
+ get => _elapsed;
+ set { if (Set(ref _elapsed, value)) Raise(nameof(Subtitle)); }
+ }
+
+ public string Subtitle =>
+ _modelId is null
+ ? string.Empty
+ : $"{_modelId} · {(_elapsed.TotalSeconds < 60 ? $"{_elapsed.TotalSeconds:0.0}s" : $"{(int)_elapsed.TotalMinutes}m {_elapsed.Seconds}s")}";
+
+ public void Append(string delta)
+ {
+ if (string.IsNullOrEmpty(delta)) return;
+ Text += delta;
+ RebuildBlocks();
+ }
+
+ /// Discards everything from an abandoned attempt — see .
+ public void Reset()
+ {
+ Text = string.Empty;
+ Blocks.Clear();
+ }
+
+ public void Complete()
+ {
+ IsStreaming = false;
+ RebuildBlocks();
+ }
+
+ private void RebuildBlocks()
+ {
+ var parsed = MarkdownBlock.Parse(_text);
+
+ // Replace in place rather than clearing: clearing makes the list view flash and lose scroll
+ // position on every delta, which is very visible while a reply streams.
+ for (var i = 0; i < parsed.Count; i++)
+ {
+ if (i < Blocks.Count)
+ {
+ if (!Blocks[i].Equals(parsed[i])) Blocks[i] = parsed[i];
+ }
+ else
+ {
+ Blocks.Add(parsed[i]);
+ }
+ }
+
+ while (Blocks.Count > parsed.Count) Blocks.RemoveAt(Blocks.Count - 1);
+ }
+}
diff --git a/src/OpenKey.Gui/ViewModels/ModelChoice.cs b/src/OpenKey.Gui/ViewModels/ModelChoice.cs
new file mode 100644
index 0000000..dd93d6e
--- /dev/null
+++ b/src/OpenKey.Gui/ViewModels/ModelChoice.cs
@@ -0,0 +1,30 @@
+using OpenKey.Core.Providers;
+
+namespace OpenKey.Gui.ViewModels;
+
+///
+/// An entry in the model picker.
+///
+/// A wrapper rather than binding directly, because the list needs one item
+/// that is not a model: "Automatic". Without it, picking a model was a one-way door —
+/// nothing in the window could hand the choice back to rotation, which is the default and the
+/// right setting for most people.
+///
+///
+public sealed record ModelChoice(string Label, string? Detail, ModelInfo? Model)
+{
+ public bool IsAutomatic => Model is null;
+
+ public bool HasDetail => !string.IsNullOrEmpty(Detail);
+
+ public static ModelChoice Automatic { get; } =
+ new("Automatic", "Best available, switches when one is busy", null);
+
+ public static ModelChoice For(ModelInfo model) =>
+ new(model.DisplayName, FormatContext(model.ContextLength), model);
+
+ private static string FormatContext(int contextLength) =>
+ contextLength <= 0 ? string.Empty
+ : contextLength >= 1000 ? $"{contextLength / 1000}k context"
+ : $"{contextLength} context";
+}
diff --git a/src/OpenKey.Gui/ViewModels/ObservableObject.cs b/src/OpenKey.Gui/ViewModels/ObservableObject.cs
new file mode 100644
index 0000000..db73b30
--- /dev/null
+++ b/src/OpenKey.Gui/ViewModels/ObservableObject.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace OpenKey.Gui.ViewModels;
+
+///
+/// Minimal INotifyPropertyChanged base.
+///
+/// Hand-rolled rather than pulling in an MVVM toolkit: this is the only thing such a package would
+/// be used for here, and the source generators most of them rely on interact badly with the AOT
+/// build. Twenty lines is cheaper than the dependency.
+///
+///
+public abstract class ObservableObject : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected void Raise([CallerMemberName] string? name = null) =>
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+
+ protected bool Set(ref T field, T value, [CallerMemberName] string? name = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return false;
+ field = value;
+ Raise(name);
+ return true;
+ }
+}
diff --git a/src/OpenKey.Gui/ViewModels/RelayCommand.cs b/src/OpenKey.Gui/ViewModels/RelayCommand.cs
new file mode 100644
index 0000000..c82ac5d
--- /dev/null
+++ b/src/OpenKey.Gui/ViewModels/RelayCommand.cs
@@ -0,0 +1,20 @@
+using System.Windows.Input;
+
+namespace OpenKey.Gui.ViewModels;
+
+///
+/// Minimal for the window's key bindings. Buttons call view-model methods
+/// directly, so nothing here needs parameters or dynamic enablement.
+///
+public sealed class RelayCommand : ICommand
+{
+ private readonly Action _execute;
+
+ public RelayCommand(Action execute) => _execute = execute;
+
+ public event EventHandler? CanExecuteChanged { add { } remove { } }
+
+ public bool CanExecute(object? parameter) => true;
+
+ public void Execute(object? parameter) => _execute();
+}
diff --git a/src/OpenKey.Gui/ViewModels/SyntaxHighlighter.cs b/src/OpenKey.Gui/ViewModels/SyntaxHighlighter.cs
new file mode 100644
index 0000000..29e2d5f
--- /dev/null
+++ b/src/OpenKey.Gui/ViewModels/SyntaxHighlighter.cs
@@ -0,0 +1,145 @@
+using System.Text.RegularExpressions;
+
+namespace OpenKey.Gui.ViewModels;
+
+public enum TokenKind
+{
+ Plain,
+ Keyword,
+ StringLiteral,
+ Comment,
+ Number,
+ Type,
+}
+
+public sealed record CodeToken(string Text, TokenKind Kind);
+
+///
+/// Colours code blocks in replies.
+///
+/// Deliberately a small tokenizer rather than an AvaloniaEdit or TextMate dependency. Those are
+/// built for editing — buffers, folding, undo, grammar files — and none of that applies to text
+/// that is displayed once and never modified. The cost of getting this slightly wrong is a keyword
+/// in the wrong colour; the cost of the dependency is megabytes and an AOT risk.
+///
+///
+/// Comments and strings are matched first and win, because a keyword inside a comment is not a
+/// keyword. Anything unrecognised renders plain, which is the correct failure mode.
+///
+///
+internal static class SyntaxHighlighter
+{
+ private const RegexOptions Opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
+
+ // One pass, alternation ordered by precedence: whatever matches first wins the span.
+ private static readonly Regex CStyle = new(
+ """
+ (?//[^\n]*|/\*[\s\S]*?\*/)
+ |(?"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)
+ |(?\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?[fFdDmMlLuU]?\b)
+ |(?[A-Za-z_][A-Za-z0-9_]*)
+ """,
+ Opts | RegexOptions.IgnorePatternWhitespace);
+
+ // Five-quote delimiter: the pattern itself matches Python's triple-quoted strings, which would
+ // otherwise close a three-quote raw literal.
+ private static readonly Regex HashStyle = new(
+ """""
+ (?\#[^\n]*)
+ |(?"""[\s\S]*?"""|'''[\s\S]*?'''|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')
+ |(?\b\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?\b)
+ |(?[A-Za-z_][A-Za-z0-9_]*)
+ """"",
+ Opts | RegexOptions.IgnorePatternWhitespace);
+
+ private static readonly Regex SqlStyle = new(
+ """
+ (?--[^\n]*|/\*[\s\S]*?\*/)
+ |(?'(?:''|[^'])*')
+ |(?\b\d+(?:\.\d+)?\b)
+ |(?[A-Za-z_][A-Za-z0-9_]*)
+ """,
+ Opts | RegexOptions.IgnorePatternWhitespace);
+
+ private static readonly HashSet CommonKeywords = new(StringComparer.Ordinal)
+ {
+ "if", "else", "for", "while", "return", "break", "continue", "switch", "case", "default",
+ "try", "catch", "finally", "throw", "new", "class", "struct", "enum", "interface",
+ "public", "private", "protected", "internal", "static", "const", "readonly", "async",
+ "await", "using", "namespace", "var", "let", "const", "function", "def", "import", "from",
+ "export", "type", "true", "false", "null", "nil", "None", "True", "False", "this", "self",
+ "in", "is", "not", "and", "or", "with", "as", "yield", "lambda", "pass", "elif", "raise",
+ "match", "record", "override", "virtual", "abstract", "sealed", "out", "ref", "params",
+ };
+
+ private static readonly HashSet SqlKeywords = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "select", "from", "where", "join", "inner", "left", "right", "outer", "on", "group", "by",
+ "order", "having", "insert", "into", "values", "update", "set", "delete", "create", "table",
+ "alter", "drop", "index", "view", "as", "and", "or", "not", "null", "is", "in", "exists",
+ "distinct", "limit", "offset", "union", "all", "case", "when", "then", "else", "end",
+ "primary", "key", "foreign", "references", "default", "constraint", "with",
+ };
+
+ private static readonly HashSet KnownTypes = new(StringComparer.Ordinal)
+ {
+ "string", "int", "long", "bool", "double", "float", "decimal", "byte", "char", "object",
+ "void", "str", "list", "dict", "set", "tuple", "number", "boolean", "any", "unknown",
+ "String", "Int32", "Boolean", "Task", "List", "Dictionary", "Array", "Object",
+ };
+
+ ///
+ /// Splits code into coloured spans. An unrecognised or absent language yields a single plain
+ /// token, so callers never need to special-case it.
+ ///
+ public static IReadOnlyList Tokenize(string code, string? language)
+ {
+ if (string.IsNullOrEmpty(code)) return Array.Empty();
+
+ var (regex, keywords) = Dialect(language);
+ if (regex is null) return new[] { new CodeToken(code, TokenKind.Plain) };
+
+ var tokens = new List();
+ var last = 0;
+
+ foreach (Match m in regex.Matches(code))
+ {
+ if (m.Index > last) tokens.Add(new CodeToken(code[last..m.Index], TokenKind.Plain));
+
+ var kind = TokenKind.Plain;
+ if (m.Groups["comment"].Success) kind = TokenKind.Comment;
+ else if (m.Groups["string"].Success) kind = TokenKind.StringLiteral;
+ else if (m.Groups["number"].Success) kind = TokenKind.Number;
+ else if (m.Groups["word"].Success)
+ {
+ var word = m.Value;
+ if (keywords.Contains(word)) kind = TokenKind.Keyword;
+ else if (KnownTypes.Contains(word)) kind = TokenKind.Type;
+ }
+
+ tokens.Add(new CodeToken(m.Value, kind));
+ last = m.Index + m.Length;
+ }
+
+ if (last < code.Length) tokens.Add(new CodeToken(code[last..], TokenKind.Plain));
+ return tokens;
+ }
+
+ private static (Regex? Regex, HashSet Keywords) Dialect(string? language) =>
+ (language ?? string.Empty).Trim().ToLowerInvariant() switch
+ {
+ "python" or "py" or "ruby" or "rb" or "sh" or "bash" or "shell" or "zsh" or "yaml" or "yml" or "toml"
+ => (HashStyle, CommonKeywords),
+
+ "sql" or "postgres" or "postgresql" or "mysql" or "sqlite"
+ => (SqlStyle, SqlKeywords),
+
+ "c" or "cpp" or "c++" or "cs" or "csharp" or "c#" or "java" or "js" or "javascript"
+ or "ts" or "typescript" or "jsx" or "tsx" or "go" or "rust" or "rs" or "kotlin"
+ or "swift" or "php" or "scala" or "dart" or "json"
+ => (CStyle, CommonKeywords),
+
+ // Unknown or absent language: render plain rather than guess wrong.
+ _ => (null, CommonKeywords),
+ };
+}
diff --git a/src/OpenKey.Gui/Views/CodeBlockView.axaml b/src/OpenKey.Gui/Views/CodeBlockView.axaml
new file mode 100644
index 0000000..30c5451
--- /dev/null
+++ b/src/OpenKey.Gui/Views/CodeBlockView.axaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenKey.Gui/Views/CodeBlockView.axaml.cs b/src/OpenKey.Gui/Views/CodeBlockView.axaml.cs
new file mode 100644
index 0000000..7f5f705
--- /dev/null
+++ b/src/OpenKey.Gui/Views/CodeBlockView.axaml.cs
@@ -0,0 +1,93 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.Media;
+using OpenKey.Gui.ViewModels;
+
+namespace OpenKey.Gui.Views;
+
+public partial class CodeBlockView : UserControl
+{
+ public CodeBlockView()
+ {
+ InitializeComponent();
+ DataContextChanged += (_, _) => Render();
+ }
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ private void Render()
+ {
+ if (DataContext is not MarkdownBlock block) return;
+
+ var label = this.FindControl("LanguageLabel");
+ if (label is not null)
+ {
+ label.Text = block.Language ?? string.Empty;
+ label.IsVisible = block.Language is not null;
+ }
+
+ var text = this.FindControl("CodeText");
+ if (text is null) return;
+
+ text.Inlines?.Clear();
+
+ var tokens = SyntaxHighlighter.Tokenize(block.Text, block.Language);
+
+ // A single plain token is the common case for unknown languages; skip the Inlines
+ // machinery entirely so it renders as ordinary text.
+ if (tokens.Count == 1 && tokens[0].Kind == TokenKind.Plain)
+ {
+ text.Text = tokens[0].Text;
+ return;
+ }
+
+ text.Text = null;
+ foreach (var token in tokens)
+ {
+ text.Inlines?.Add(new Run(token.Text) { Foreground = BrushFor(token.Kind) });
+ }
+ }
+
+ ///
+ /// Resolved from the active theme rather than hardcoded, so code colours follow a theme switch
+ /// like everything else.
+ ///
+ private IBrush BrushFor(TokenKind kind)
+ {
+ var key = kind switch
+ {
+ TokenKind.Keyword => "CodeKeyword",
+ TokenKind.StringLiteral => "CodeString",
+ TokenKind.Comment => "CodeComment",
+ TokenKind.Number => "CodeNumber",
+ TokenKind.Type => "CodeType",
+ _ => "Body",
+ };
+
+ return this.TryFindResource(key, out var value) && value is IBrush brush
+ ? brush
+ : Brushes.Gray;
+ }
+
+ private async void OnCopy(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is not MarkdownBlock block) return;
+
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard is null) return;
+
+ await clipboard.SetTextAsync(block.Text);
+
+ // Confirm in place. A toast would be more machinery for less clarity.
+ if (sender is Button button)
+ {
+ button.Content = "Copied";
+ await Task.Delay(1400);
+ button.Content = "Copy";
+ }
+ }
+}
diff --git a/src/OpenKey.Gui/Views/ConfirmWindow.axaml b/src/OpenKey.Gui/Views/ConfirmWindow.axaml
new file mode 100644
index 0000000..78c33c7
--- /dev/null
+++ b/src/OpenKey.Gui/Views/ConfirmWindow.axaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs b/src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs
new file mode 100644
index 0000000..3db42f9
--- /dev/null
+++ b/src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs
@@ -0,0 +1,26 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace OpenKey.Gui.Views;
+
+public partial class ConfirmWindow : Window
+{
+ public ConfirmWindow() => InitializeComponent();
+
+ public ConfirmWindow(string title, string body, string confirmLabel) : this()
+ {
+ Title = title;
+ this.FindControl("TitleText")!.Text = title;
+ this.FindControl("BodyText")!.Text = body;
+ this.FindControl