Skip to content

v0.4.0 — chat history, a visual system, and an identity - #9

Open
corecompiled wants to merge 14 commits into
mainfrom
feat/chat-history
Open

v0.4.0 — chat history, a visual system, and an identity#9
corecompiled wants to merge 14 commits into
mainfrom
feat/chat-history

Conversation

@corecompiled

Copy link
Copy Markdown
Owner

Thirteen commits: the three chat-history ones that were waiting on a review, plus ten from a session of GUI work driven by hands-on feedback.

What changed

Conversations you can go back to. One JSON file per chat under chats\, with index.json as a rebuildable cache validated by id rather than count. Migration turns an existing session.json into the first chat and renames the original rather than deleting it.

A palette chosen for this app. Every token was previously an unmodified Tailwind swatch. One rule now governs all four themes: chrome recedes, content advances, overlays float. Light took two rounds — it was flat first (a 1.07:1 step between surfaces, invisible), then measurement found the real fault: body text was fine at 15:1 while everything using Muted sat at the 5.0:1 AA floor, and that covered most of the chrome. mono was not proving its own claim — Body, Brand, Ok and CodeType were the same grey.

Controls that respond. Hover, press, disabled, and a keyboard-only focus ring — Fluent 12 ships Button.FocusAdorner as null, so tabbing through the header previously showed nothing at all. Two real bugs fixed: the primary button lost its brand colour on hover and the destructive menu item lost its red, both because Fluent's ControlTheme sets the template part directly and a control-level Background loses to it.

An identity. A mark, and an icon on both executables — they shipped with the default blank page. Deliberately not a key: every credential manager owns that shape in the same 16px taskbar slot.

Inline formatting, a resizable full-height sidebar, right-anchored message bubbles, a thinking indicator, /name, and OPENKEY_HOME.

Contract changes

  • config.json gains userName. Null means "use the Windows account name", which is what shipped before, so no existing install changes behaviour. Display only — never sent to a provider, never in an export.
  • %APPDATA%\OpenKey\ gains chats\ (from the chat-history commits).

docs/05-persistence-and-reset.md is updated for both.

Bugs fixed along the way

  • A command with a leading space — or an invisible character from a paste — was not recognised and went to the model as a message.
  • Copy copied the latest reply rather than the one you were looking at.
  • "Try again" appended a second copy of your message instead of replacing the failed one.
  • Hiding the chat list left an empty strip where it had been.
  • Switching theme left the status bar the previous theme's colour.
  • The composer sat 236px left of every reply, tucked under the chat list.

Verification

207 tests. Both binaries published as NativeAOT and launched; the layout, palette and indicator work was checked against captured frames and measured pixel contrast rather than assumed — four defects in this branch compiled cleanly and passed the suite while being visibly wrong on screen.

CI has not run on any of this: ci.yml triggers on push: [main] and pull_request, so this PR is its first build.

Not included

The Scoop manifest stays at 0.3.0 — it points at release download URLs and can only move once a v0.4.0 release exists.

corecompiled and others added 14 commits August 3, 2026 21:07
OpenKey stored exactly one conversation. Starting another destroyed the
previous one, and the app's "New chat" button promised something that did
not exist. This replaces the single session with a store of many.

Storage is one JSON file per conversation under chats\, with index.json as
a list cache. A folder rather than one document, so a corrupt write costs a
single conversation instead of all of them — the same reasoning that
quarantines a bad session rather than refusing to start. The index is never
authoritative: it is validated against the actual files by id, not by
count, and rebuilt whenever it disagrees. That check originally compared
counts, which meant an index with the right number of wrong entries was
trusted; a test caught it.

Migration turns an existing session.json into the first chat, titled from
its opening message, and renames the original to session.json.migrated
rather than deleting it — a failed migration must never be the reason
someone loses the only conversation they had. Verified against real data.

Titles come from the first user message rather than a model call. A title
is cosmetic and renameable, so paying tokens and latency for one on every
new chat is a bad trade.

Console gains /chats, /chat <n>, /rename and /delete; /new stops
destroying. The app gains a sidebar with click-to-switch, right-click to
rename or delete, a collapse toggle, and Ctrl+N.

The Undo added earlier is gone with the problem it patched: it existed
because clearing destroyed the only conversation. Starting a new chat now
destroys nothing and needs no undo, while deleting one is permanent and so
asks first.

Contract change, approved beforehand: ISessionStore is replaced by
IChatStore and the %APPDATA% layout gains chats\. Both hosts updated.

145 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Something I broke an hour ago. With one conversation there was nothing to
name; with several, the console gave no indication which one you were
looking at unless you typed /chats. The app has a sidebar for this and the
console had nothing.

The chat title now appears under the header, and in /about. Shown only once
a conversation has something in it — naming an empty chat is noise.

Also corrects the getting-started panel, which still described /new as
starting a fresh conversation. It keeps the old one now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
It was printed after a line was read, so it appeared once the user had
already typed — reading as a response to what they typed rather than an
aside. Now it sits above the prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Messages were labelled with the Windows account name, which is right often
enough that asking would be a tax and wrong often enough to be worth
changing. So it stays the default and there is no prompt: the first run
already asks for a key, and a second question before anything useful has
happened buys nothing when the answer is usually already correct.

config.json gains userName. Null means "use the Windows account name",
which is what shipped before, so no existing install changes behaviour.

Display only. It never enters a ChatMessage, is never sent to a provider,
and never appears in an export — exports still say "You", so a transcript
you share does not carry a name you did not choose to put in it.

Resolved in Core rather than in each host, so the console and the app
cannot drift on what to call you. The trim and the 32-character cap live
there too, which means a hand-edited config.json gets the same treatment as
a name typed into the app.

Contract change to config.json, so docs/05 is updated with it.

A round-trip test caught the interesting bug: the store rebuilds the record
field by field when it normalises, so the new field was written and then
silently dropped on load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Pasted text arrives from editors, web pages and files carrying characters
nobody typed and nobody can see. This strips the ones that would sit in
front of a command, and trims.

Only U+FEFF is removed throughout, because a byte-order mark is an encoding
artifact and its old zero-width-no-break-space meaning is deprecated. The
rest are stripped from the front only: U+200D is load-bearing inside emoji
sequences, and removing it everywhere would break a family emoji into
separate people. There is a test for that, because it is exactly what a
later simplification would get wrong.

Two entry points rather than one. Normalize also trims leading whitespace
and suits command detection; StripInvisible leaves every visible character
where it was and suits a message body, where trimming the front would eat
the indentation of the first line of a pasted code block.

Nothing consumes this yet — the hosts are wired up in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
…ommand

Command detection ran on the raw line, so anything before the slash stopped
it being recognised and the line went to the model as a message instead.

A leading space did it, which is easy to hit and was the more likely fault
of the two. A byte-order mark did it as well, which is how it was found:
piping a UTF-8 file with its BOM intact produced "/help" that did not start
with "/".

The line is now normalised once at the console boundary, so the command
router and the message that reaches the model see the same clean text, and
again inside HandleAsync so the guarantee does not depend on the caller.
The app composer strips invisible characters too, but keeps leading
whitespace: a pasted code block should not lose the indentation of its
first line.

Also adds /name, which changes what OpenKey calls you and is documented in
the user guide and the README command table alongside the other verbs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
There was no way to run the app without pointing it at the only copy of
someone's conversations. That is a poor property for a tool whose own
testing involves creating and deleting chats, and it cost real chat files
to learn.

Unset behaves exactly as before. A blank or whitespace value is treated as
unset rather than as a path, so a leftover empty variable in a shell script
falls back to %APPDATA% instead of resolving to the current directory.

The key stays DPAPI-encrypted for the current Windows account wherever the
folder lives, so this is not a route to a portable key file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Both executables shipped with the default icon, so Explorer, the taskbar
and alt-tab showed a blank page for a tool people are told to copy onto a
USB stick and hand to someone.

The mark is the letter K pulled open: a full-height stem with the arms
detached and floating clear of it as a chevron, so the letter reads as an
opening rather than a closed form. The gap is the whole idea, and it is the
part that survives at 16px because it is empty space rather than detail.
The chevron is already on screen — it is the console prompt.

Deliberately not a key. 1Password, Bitwarden, KeePass and Keeper all own
key marks in exactly the 16px taskbar slot this ships into, so a key would
read "password manager" rather than "AI client" — a product risk, not a
near miss. It also inverts the promise: a key means "I hold something
shut", while OpenKey removes an obstacle. And "open key" already means a
public key, which is the one thing this key is not.

Two geometries. At 24px and above the stroked master scales with integer
stroke weights. At 16 and 20 a hinted variant takes over: under a straight
scale the round apex collapses to a one-pixel spike that antialiases into a
smudge, so the hint holds a 2px stroke with a flat nose and absorbs the
difference in the arm angle, which nobody perceives.

The .ico is a committed artefact, not a build step, so the publish pipeline
needs no image toolchain. tools/make-icon.ps1 regenerates it from
System.Drawing alone — present on every Windows box, nothing to install.
Entries below 256 are BMP and 256 is PNG, which is what real icon tooling
emits: PNG at every size is legal on Windows 10 and later but is not
universally decodable, and System.Drawing.Icon refuses such a file
outright.

One icon for both executables. OpenKey.exe and OpenKeyApp.exe are one
product with two front doors, and two icons would imply two products.

Also documents the prerequisites that make a local AOT publish work, both
of which were wrong on the machine this was built on: the C++ workload, and
vswhere on PATH — without the latter, vcvarsall prints to stderr, MSBuild
merges that into the probe's output, and the compiler builds a linker path
out of an error message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
…ng used

Every colour token was an unmodified Tailwind swatch — slate-900,
cyan-400, amber-400. That is what reads as generic: not an impression, but
a fact about where the numbers came from.

One rule now governs all four palettes: chrome recedes, content advances,
overlays float. The old one did the reverse, with the header and sidebar
sitting above the transcript, which is most of why the window never
resolved into planes.

Light took two rounds. It was flat first — Surface and SurfaceRaised were
3 L* apart, a 1.07:1 step nobody can see, so the window was one sheet held
together by hairlines. Fixing that overcorrected into beige, and then
measurement found the real fault: body text was fine at 15:1 while
everything using Muted sat at 5.0:1, the bare AA floor, and that covered
most of the chrome — buttons, captions, hints, counts. The same role in
dark measured 6.9. Muted now clears 7.2:1 in every theme.

mono is the standing proof that nothing depends on hue, and it was not
proving it: Body, Brand, Ok and CodeType were the same grey. It now uses
separated luminance steps, with weight and slant carrying the pairs that
are too close to separate on lightness alone.

Two bugs behind "the buttons feel placed rather than designed". Fluent's
ControlTheme sets the template part directly, so a Background on the
control loses the moment the pointer arrives: the primary button dropped
its brand colour and turned Fluent grey on hover, and the destructive menu
item lost its red exactly when you pointed at it. Every colour state now
targets the part.

The styles moved to application scope. They were on MainWindow, so the two
dialogs could not see them and rendered as raw Fluent — the single largest
reason the app looked half-finished. Both now use the app's own controls,
and the erase-everything confirmation no longer paints a hardcoded maroon
button that ignored the theme.

Keyboard focus is visible for the first time. Fluent 12 ships
Button.FocusAdorner as null, so tabbing through the header showed nothing
at all. The ring is keyboard-only via :focus-visible and reserves its 2px
at rest with a transparent brush, because nothing that affects layout may
animate.

Also removes a duplicate service registration, and stops the status bar
resolving four hardcoded dark-theme hex values — which showed dark colours
in the light theme and put the only four hues into mono.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Inline formatting was flattened to plain text, so a reply full of emphasis
arrived looking uniform and a link showed its address in brackets after the
label.

Blocks now carry a list of styled runs alongside the plain text. Markdig
does the parsing in both hosts, so the console and the app cannot drift on
what counts as emphasis.

Style is a flag set, OR-ed down the tree rather than assigned, because
markdown nests: *** is bold wrapping italic, and assigning would drop the
outer one. Run colours bind as DynamicResource rather than resolving once,
so a theme switch repaints a transcript that is already on screen — a
resolved brush would be right when the message rendered and wrong after.

Plain text stays the copy and export source. A pasted transcript should not
carry styling the destination cannot honour.

The list marker is its own run, so a bullet whose first word is bold does
not embolden the bullet.

Also gives the code fence a real edge. It used the divider hairline, which
in mono left the snippet floating on a page it barely differed from — the
block measured 1.048 against the surface behind it, which is no plane at
all — while three of the six token roles clustered at the top of the
luminance range and comments sat below 5:1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
The header held a 260px model picker and four buttons of identical weight,
so the second most frequent action looked the same as the yearly one. Each
one moves to where it acts, rather than being tidied away.

New chat goes to the head of the chat list, where the chat it creates
appears. It is not duplicated: the header keeps a stand-in shown only when
the list is hidden, so there is never a second copy and never none, and
Ctrl+N always has a visible affordance.

Copy goes onto each reply. It had been copying the most recent reply
regardless of which one you were reading, so scrolling up and pressing it
silently gave you a different message. It is revealed on hover rather than
behind a right-click, because turns are selectable text and right-click
already belongs to selection there — a custom menu would work on the
padding and not on the prose.

Retry becomes "Try again" on the message that went unanswered. A failed or
stopped send deletes its empty reply, so a transcript ending on your own
turn is a reliable signal. It belongs there rather than in the status bar
because the status bar is dismissible and the draft box has already been
cleared, leaving the transcript as the only place the text survives. It
also stops appending a second copy of the message instead of replacing the
one that failed.

The model picker moves beside the composer. It is an input to the send you
are about to make, not a toolbar setting.

The chat list now spans the window height with the composer confined to the
conversation column, which is also what aligns the composer with the
transcript — it previously sat 236px to the left of every reply, tucked
under the chat list, because it lived in the root grid and never inherited
the sidebar's offset.

Your messages are bubbles that hug their text and sit right; replies stay
full width on the left. Stretching to the column meant one wide code fence
in one reply inflated every short message in the conversation. The composer
spans the pane instead of matching the 720 reading cap: that cap existed so
the field previewed the shape of the message, which stopped being true once
messages became bubbles, and at 1920 it left 820px of empty bar beside
Send.

Three dots pulse while the model is thinking, from send until the first
token — where the wait actually is on a busy free model. Not the mark
spinning: animating the logo would tie it permanently to "busy". The caret
now appears only once text is arriving, because a caret blinking at nothing
reads as a stall.

Confirmations clear themselves after five seconds; warnings and failures
wait to be dismissed, since they are often the only thing carrying the
reason. The banner is tinted per severity — it painted every message on a
surface that in the light theme was white on near-white, so a deletion and
a failure looked identical and only a 2px edge carried the difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
CHANGELOG gains the unreleased entries in user-facing terms. BACKLOG gains
what was done and why, plus two things that are not done.

The project rename is recorded now rather than when a name is picked,
because the surface area is wider than a find-and-replace and some of it
cannot be renamed quietly. %APPDATA%\OpenKey\ is a contract surface, so
renaming the folder strands every existing key, chat and setting unless a
migration copies it forward. The update checker has a hardcoded endpoint
among 91 references to the repo path, and renaming the repo before a
release exists under the new name makes installed copies stop seeing
updates. The mark is a stylised K, so a name starting with a different
letter invalidates it — the name and the mark have to be decided together.

Also notes the two open items: the sidebar width is not persisted, because
GUI-only layout state does not belong in a config file shared with a
console that has no sidebar; and turn actions are 34px against a 26px
precedent elsewhere, which wants deciding rather than splitting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
The reading cap sat on the column holding the turns, and that column is
pinned left. So a bubble right-aligned to the cap rather than to the pane
and stopped about 140px short of it — and once the composer was widened to
span the pane, the message box visibly reached further right than any
message above it.

The cap moves onto the turn itself, which is what lets the two speakers be
bounded differently: a reply is held to the reading width and stays left,
while a bubble anchors to the right edge of the pane and is level with the
composer. The column simply spans.

The cap binds with DynamicResource rather than StaticResource. A
StyleInclude is processed before Application.Resources is populated, so the
static form cannot resolve and takes the app down at startup with
KeyNotFoundException — which is how this was found, and is worth knowing
before adding another resource reference to that file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
Ten commits of work were shipping under the number of the release before
them, so a build from this branch was indistinguishable from the published
v0.3.0 while behaving very differently.

One line changes it. Directory.Build.props holds the only version in the
codebase; the SDK turns it into AssemblyInformationalVersion, and both
surfaces read that attribute rather than carrying a literal — the console
banner and the app header, each trimming the commit sha the SDK appends.
Verified after the bump rather than assumed: banner, header label and the
Explorer metadata on both executables all read 0.4.0.

Minor, not major: nothing here breaks an existing install. The %APPDATA%
layout gains one optional field, an absent userName behaves exactly as
before, and OPENKEY_HOME is inert unless set.

The CHANGELOG's Unreleased section closes as 0.4.0.

Also corrects the versioning section of docs/06, which described a
Directory.Build.props that does not look like that, and mapped releases
onto internal phase numbers — claiming the GUI would be 2.0.0 when it
shipped in 0.3.0. Phases describe the order work happens in, not the
version it lands under, and the same document already forbids phase
numbering on public surfaces.

The Scoop manifest is deliberately left at 0.3.0. It points at release
download URLs, so it can only move once a v0.4.0 release exists — the same
separate step taken for v0.2.1 and v0.3.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ
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