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
22 changes: 13 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
17 changes: 13 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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:
Expand Down
21 changes: 19 additions & 2 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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

Expand Down
34 changes: 33 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>Recommended</AnalysisMode>
<Version>0.2.1</Version>
<Version>0.3.0</Version>
<NeutralLanguage>en-US</NeutralLanguage>
</PropertyGroup>
</Project>
3 changes: 3 additions & 0 deletions OpenKey.slnx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/OpenKey/OpenKey.csproj" />
<Project Path="src/OpenKey.Windows/OpenKey.Windows.csproj" />
<Project Path="src/OpenKey.Gui/OpenKey.Gui.csproj" />
<Project Path="src/OpenKey.Core/OpenKey.Core.csproj" />
<Project Path="src/OpenKey.Providers.OpenRouter/OpenKey.Providers.OpenRouter.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OpenKey.Core.Tests/OpenKey.Core.Tests.csproj" />
<Project Path="tests/OpenKey.Tests/OpenKey.Tests.csproj" />
<Project Path="tests/OpenKey.Gui.Tests/OpenKey.Gui.Tests.csproj" />
</Folder>
<Folder Name="/docs/">
<File Path="docs/00-overview.md" />
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
22 changes: 15 additions & 7 deletions docs/07-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatChunk>` 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

Expand Down
37 changes: 37 additions & 0 deletions docs/architecture/08-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/OpenKey.Core/Engine/ChatEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,25 @@ public Task NewSessionAsync(CancellationToken ct)
return Task.CompletedTask;
}

/// <summary>
/// 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.
/// </summary>
public async Task RestoreTurnsAsync(IReadOnlyList<ChatMessage> 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<ChatChunk> SendAsync(
string userText,
[EnumeratorCancellation] CancellationToken ct)
Expand Down
31 changes: 31 additions & 0 deletions src/OpenKey.Gui/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="OpenKey.Gui.App"
RequestedThemeVariant="Dark">

<Application.Styles>
<FluentTheme />
</Application.Styles>

<!-- Seed values only. GuiTheme.Apply overwrites every one of these at startup and on a theme
switch, which is why the views bind with DynamicResource rather than StaticResource. -->
<Application.Resources>
<SolidColorBrush x:Key="Surface" Color="#0F172A" />
<SolidColorBrush x:Key="SurfaceRaised" Color="#1E293B" />
<SolidColorBrush x:Key="CodeSurface" Color="#0B1220" />
<SolidColorBrush x:Key="Line" Color="#334155" />
<SolidColorBrush x:Key="Body" Color="#E2E8F0" />
<SolidColorBrush x:Key="Muted" Color="#94A3B8" />
<SolidColorBrush x:Key="Brand" Color="#22D3EE" />
<SolidColorBrush x:Key="Ok" Color="#4ADE80" />
<SolidColorBrush x:Key="Warn" Color="#FBBF24" />
<SolidColorBrush x:Key="Danger" Color="#F87171" />
<SolidColorBrush x:Key="OnBrand" Color="#04121A" />

<SolidColorBrush x:Key="CodeKeyword" Color="#C4B5FD" />
<SolidColorBrush x:Key="CodeString" Color="#86EFAC" />
<SolidColorBrush x:Key="CodeComment" Color="#64748B" />
<SolidColorBrush x:Key="CodeNumber" Color="#FCD34D" />
<SolidColorBrush x:Key="CodeType" Color="#7DD3FC" />
</Application.Resources>
</Application>
35 changes: 35 additions & 0 deletions src/OpenKey.Gui/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -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<MainWindowViewModel>();

// 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();
}
}
Loading
Loading