v0.4.0 — chat history, a visual system, and an identity - #9
Open
corecompiled wants to merge 14 commits into
Open
v0.4.0 — chat history, a visual system, and an identity#9corecompiled wants to merge 14 commits into
corecompiled wants to merge 14 commits into
Conversation
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
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.
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\, withindex.jsonas a rebuildable cache validated by id rather than count. Migration turns an existingsession.jsoninto 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
Mutedsat at the 5.0:1 AA floor, and that covered most of the chrome.monowas 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.FocusAdorneras 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'sControlThemesets the template part directly and a control-levelBackgroundloses 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, andOPENKEY_HOME.Contract changes
config.jsongainsuserName. 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\gainschats\(from the chat-history commits).docs/05-persistence-and-reset.mdis updated for both.Bugs fixed along the way
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.ymltriggers onpush: [main]andpull_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.