Skip to content

Phase 2: Avalonia GUI - #3

Merged
corecompiled merged 5 commits into
mainfrom
feat/avalonia-gui
Aug 3, 2026
Merged

Phase 2: Avalonia GUI#3
corecompiled merged 5 commits into
mainfrom
feat/avalonia-gui

Conversation

@corecompiled

Copy link
Copy Markdown
Owner

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.exe ships alongside OpenKey.exe from the same tag — same engine, same saved conversation, same key.

A second host, not a second implementation

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 real test of the layering, and it held — the GUI needed no change to OpenKey.Core. The one thing it forced was extracting OpenKey.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 targets net10.0 rather than net10.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, IsAttemptRestart clearing 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.json and is shared, so switching in one host and opening the other keeps your choice; the rendering is per-host. Every brush moved to DynamicResource so a switch repaints live, code colours included.

mono isn't a novelty — it's the standing test that no state is signalled by colour alone.

Caught while building

  • C# raw strings can't contain the Python triple-quote the regex needed (five-quote delimiter).
  • Avalonia 12 replaced IClipboard.SetTextAsync with a DataTransfer model plus a ClipboardExtensions helper.
  • Earlier: the model picker rendered a record's ToString(), a collection count was bound to a bool, and a bare number bound to Margin would 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

corecompiled and others added 3 commits August 3, 2026 15:42
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 and others added 2 commits August 3, 2026 17:17
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
@corecompiled
corecompiled merged commit 6b40238 into main Aug 3, 2026
1 check passed
@corecompiled
corecompiled deleted the feat/avalonia-gui branch August 3, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant