Phase 2: Avalonia GUI - #3
Merged
Merged
Conversation
A console window is intimidating to exactly the audience the product names
— someone who double-clicks an exe off a USB stick. This adds a windowed
host without touching the engine underneath.
The layering paid off here. OpenKey.Gui contains view models and views and
nothing else: no chat logic, no rotation, no persistence, no provider code.
Composition in Program.cs is line-for-line the console's, because the GUI is
a second host over ChatEngine rather than a second implementation of it.
One refactor was forced and is the right one anyway. DPAPI key storage, app
paths, the OAuth flow and the tokenizer all lived inside the console exe,
and the GUI needs every one of them. They move to a new OpenKey.Windows
project that both hosts reference. OpenKey.Core still has zero package
references and still targets net10.0 rather than net10.0-windows, so the
platform-neutral layer stays platform-neutral.
The GUI keeps the console's rules rather than inventing its own:
- Streaming renders at block granularity, so a code fence becomes a panel
once it closes while prose keeps flowing.
- IsAttemptRestart clears the in-progress reply, so a mid-reply model
switch does not show the answer twice.
- Errors say what happened and what to do next; no ChatErrorKind names
and no raw exception text reach the window.
- Erase-everything states exactly what is lost, defaults to Cancel, and
puts focus on Cancel so a stray Enter cannot destroy a key.
- One accent, one neutral, three signals — colour carries meaning.
Enter sends and Shift+Enter adds a line, which is the way round people
expect. Auto-scroll follows a streaming reply only when the view is already
at the bottom, so it cannot yank the page while someone reads back.
MarkdownBlock is a plain record with no Avalonia types in its parsing path,
which is what makes 16 tests possible without starting a window. Three
binding defects were caught that way and by reading the compiled XAML: the
model picker rendered a record's ToString instead of its name, a collection
count was bound to a bool, and a bare number bound to Margin would have
indented list items on all four sides.
Publishes AOT like the console. Not wired into the release workflow yet —
it wants a human to look at it first.
103 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
The bar was parity with the 1.2 console plus three GUI-specific items: mouse selection, copy code blocks, syntax highlighting. All three, plus the parity gap. Parity added: retry, copy last reply, export (a real save dialog rather than the console's guess-a-path-on-the-Desktop, because in a window people expect to choose), theme switching, and about. Syntax highlighting is a hand-written tokenizer, not AvaloniaEdit or a TextMate grammar engine. Those 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: a keyword in the wrong colour is invisible to most readers, while the dependency costs megabytes in a binary whose pitch is that it is 11 MB, plus an AOT risk in a build that is now fully native. One regex per dialect family, three keyword sets, comments and strings matched first because a keyword inside a comment is not a keyword, and an unknown language rendering plain rather than guessing. The invariant that matters is tested across every dialect: concatenating the tokens must reproduce the source exactly. Highlighting is a view over the text, and a dropped character would silently corrupt code someone is about to copy. Themes became real rather than a single hardcoded palette — default, dark, light and mono, in both hosts. The setting lives in config.json and is shared, so switching in one surface and opening the other keeps it; the rendering is per-host. Every brush moved to DynamicResource so a switch repaints live, including code colours. Both surfaces now ship from the same tag, and CI verifies both publish. Three things caught while building: C# raw strings cannot contain the Python triple-quote the regex needed (five-quote delimiter), Avalonia 12 replaced IClipboard.SetTextAsync with a DataTransfer model plus a ClipboardExtensions helper, and a heredoc mangled the export writer's newlines into real line breaks. 119 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Two defects found by exercising the CLI rather than reading it.
A mistyped or revoked key was accepted and saved. Validation called
ListModelsAsync, but GET /models is a *public* endpoint — it answers 200
with no Authorization header at all, so any string passed. The user got
"Key saved. You're ready to chat", then every message failed with an auth
error whose advice was to run /reset, which led straight back to the same
screen. Confirmed against the live API: /models returns 200 unauthenticated
while /key returns 401, so validation now uses /key.
It needs no contract change. Key acquisition is provider-specific and both
hosts already construct OpenRouterProvider directly to validate before
saving, so ValidateKeyAsync lives on the concrete type rather than
IChatProvider.
Every remaining Spectre prompt threw when input was not a keyboard.
NotSupportedException("Cannot show selection prompt since the current
terminal isn't interactive") is raised whenever any standard stream is
redirected, and nothing caught it — so `/models` from a pipe killed the app
with a raw exception name on screen, and so did first-run setup. The REPL
prompt had already moved to Console.ReadLine for this reason; the menus,
the confirmations and the key entry had not.
Prompts now degrade: a numbered list read from stdin instead of arrow keys,
a typed yes/no instead of a keypress, a plain read instead of a masked one
where there is nothing on screen to mask. Destructive confirmations
deliberately require an explicit "yes" and treat EOF as no.
This also corrects an earlier misreading of the AOT smoke test. Its
non-zero exit was described as OpenKey "correctly returning non-zero when
setup was abandoned". It was this crash.
Also: choosing a model claimed the choice lasted "until you close OpenKey",
which stopped being true when preferences became persistent.
Verified end to end against the live API — real key still works, bad keys
are rejected and not saved, corrupt session and config files are
quarantined and the app starts clean, /reset warns and cancels correctly,
markup in a reply renders literally, redirected output contains zero escape
sequences. 122 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
corecompiled
force-pushed
the
feat/avalonia-gui
branch
from
August 3, 2026 07:43
041eb04 to
718e703
Compare
Six problems, five of them only findable by using the window. Enter did not send. AcceptsReturn="True" makes the TextBox consume Enter and insert a newline during the tunnel phase, before any bubbling KeyDown handler runs — so setting e.Handled afterwards was too late and Enter behaved exactly like Shift+Enter. Handled on the tunnel route instead. Sending while scrolled up did nothing visible. Auto-scroll only followed when already near the bottom, which is right for text arriving on its own — never yank the page while someone is re-reading — but wrong for an explicit send. Following is now a mode the reader controls: scrolling up turns it off, returning to the bottom turns it on, and sending, retrying or clearing always re-arms it. The jump is posted rather than called directly, because the new message has not been laid out yet and scrolling in that instant stops short of the real bottom. "New chat" deleted the only conversation OpenKey stores. The label promised something that does not exist — everywhere else that phrase means the old chats are still in a sidebar. Renamed to "Clear", and made undoable rather than confirmed: a dialog interrupts everyone every time to guard against a rare mistake, while undo costs nothing until it is needed. Undo appears in the status line that announced the loss, and expires when a new message makes the old conversation genuinely gone. Choosing 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. ModelChoice adds an Automatic entry, and the picker now reflects what is actually saved rather than showing a placeholder while a model is pinned. Theme, About and Erase everything moved behind a single settings menu. They are app-level, and sitting them between Export and About put a colour preference in a row of per-conversation verbs. Erase now sits alone at the bottom of a menu in red, where it cannot be hit while reaching for Export. Also: Esc stops a reply and Ctrl+L clears, both window-level so they work wherever focus is; the composer takes focus on open and after clearing; and the minimum width goes 480 -> 560, since six controls including a dropdown were never going to fit and would have pushed the composer off-screen. RestoreTurnsAsync is added to ChatEngine to support undo. Additive, and not on any contract surface. 128 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Phase 2 ships: a windowed app alongside the console, same engine and same saved conversation underneath. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes Phase 2 to the roadmap's stated bar: parity with the 1.2 console, plus mouse selection, copy code blocks, and syntax highlighting.
OpenKeyApp.exeships alongsideOpenKey.exefrom the same tag — same engine, same saved conversation, same key.A second host, not a second implementation
OpenKey.Guicontains view models and views and nothing else: no chat logic, no rotation, no persistence, no provider code. Composition in itsProgram.csis line-for-line the console's.This was the real test of the layering, and it held — the GUI needed no change to
OpenKey.Core. The one thing it forced was extractingOpenKey.Windows: DPAPI, app paths, OAuth and the tokenizer had been sitting inside the console executable where a second host couldn't reach them. Core still has zero package references and still targetsnet10.0rather thannet10.0-windows, so a future browser or Android port is unaffected.The GUI keeps the console's rules rather than inventing its own: block-level streaming,
IsAttemptRestartclearing an abandoned reply, errors that name a next step, destructive confirms defaulting to Cancel with focus on Cancel.Syntax highlighting without the dependency
AvaloniaEdit and TextMate grammar engines are built for editing — buffers, folding, undo, grammar files, incremental re-lex. A chat reply is displayed once and never modified.
The asymmetry decides it: a keyword in the wrong colour is invisible to most readers, while the dependency costs megabytes in a binary whose entire pitch is that it's 11 MB, plus an AOT risk in a build that's now fully native.
So: one regex per dialect family (C-style, hash-comment, SQL), three keyword sets, comments and strings matched first because a keyword inside a comment is not a keyword, unknown languages rendering plain rather than guessing.
The invariant that matters is tested across every dialect — concatenating the tokens must reproduce the source exactly. Highlighting is a view over the text, and a dropped character would silently corrupt code someone is about to copy.
Themes
Four palettes (default, dark, light, mono) in both surfaces. The setting lives in
config.jsonand is shared, so switching in one host and opening the other keeps your choice; the rendering is per-host. Every brush moved toDynamicResourceso a switch repaints live, code colours included.monoisn't a novelty — it's the standing test that no state is signalled by colour alone.Caught while building
IClipboard.SetTextAsyncwith aDataTransfermodel plus aClipboardExtensionshelper.ToString(), a collection count was bound to a bool, and a bare number bound toMarginwould have indented list items on all four sides.Verified
Builds, runs, 119 tests green, zero dangling doc links. CI now verifies both binaries publish.
Not verified: how it looks. I have no eyes on the window. Worth your review before merge — streaming feel, code block readability, theme switching, and that Erase-everything defaults to Cancel.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ