diff --git a/BACKLOG.md b/BACKLOG.md
index 54f5c6d..e82580f 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -25,6 +25,147 @@ Tier numbers refer to the ladder in [`docs/07-roadmap.md`](docs/07-roadmap.md#ti
- Repo hygiene: `LICENSE` (MIT), `CHANGELOG.md`, `CONTRIBUTING.md`, `SECURITY.md`, this file, CI
and release workflows.
+### Message bubbles, waiting indicator, scratch home — 2026-08-04
+
+- **User turns hug their content and anchor right**, capped at 560 of the 720 column; replies stay
+ full width on the left. They previously stretched to the column, so one wide code fence in one
+ reply inflated every short message in the conversation.
+- **A thinking indicator** for the gap between sending and the first token, which on a busy free
+ model is where the wait actually is. Three dots on a staggered opacity animation — render-only, so
+ a long wait costs no layout passes. Deliberately **not** the mark spinning: animating the logo
+ would permanently tie it to "busy", which the identity notes rule out. The caret now shows only
+ while tokens arrive (`IsTyping`), since a caret blinking at nothing reads as a stall.
+- **`OPENKEY_HOME`** overrides the storage root. Added after a test script deleted real chat files:
+ there was no way to exercise the app without pointing it at the only copy of someone's history.
+ The key remains DPAPI-encrypted per Windows account wherever the root lives, so this is not a
+ route to a portable key.
+- Fixed: **"Try again" duplicated the message.** The engine drops a failed turn from its history but
+ the transcript keeps it, so the resend appended a second copy. Pre-existing, but invisible while
+ Retry was a header button and unavoidable once it moved onto the failed message itself.
+
+### Light and mono corrected against measurements — 2026-08-04
+
+Both found by looking at the running app and sampling pixels, not by reading the palette.
+
+- **Light was faint because of one token.** Body text measured 15.3:1, but everything using `Muted`
+ — header buttons, the sidebar caption, chat counts, the composer hint — measured **5.01–5.08:1**
+ against the sunken beige. AA, but the floor, and applied to most of the chrome; the same role in
+ dark measured 6.9–7.5. `Muted` is now 7.2:1 minimum and the surfaces lost most of their yellow
+ while keeping the 1.175 plane separation. Dark and mono got the same role lifted for parity.
+- **Mono's code block had no plane.** `CodeSurface` sat **1.048** against `Surface` — invisible — so
+ a snippet did not read as a block at all, while three of the six token roles clustered at the top
+ of the luminance range and comments sat at 4.7:1. The block is now 1.10 from the page with an
+ evenly spaced ramp, and code fences take `LineStrong` rather than the divider hairline in every
+ theme.
+- **The chat list now spans the window height**, with the composer confined to the conversation
+ column. This also deletes the gutter binding added earlier the same day: the composer inherits the
+ correct offset from its column, so there is nothing left to keep in sync.
+
+### Header actions relocated to where they act — 2026-08-04
+
+The header held a 260px model picker plus four `ghost` buttons of identical weight, so the
+second-most-frequent action looked the same as the yearly one. Reviewed by the UI specialist against
+the user's own proposals; two accepted as given, one accepted with a correction, one rejected.
+
+- **New chat → head of the chat list**, where the chat it creates appears. Not duplicated: the
+ header keeps a stand-in bound to `!ShowChats`, so there is never a second copy and never none, and
+ `Ctrl+N` always has a visible affordance. Above the `ListBox`, not inside it — a `ListBoxItem`
+ would take selection and fight the `SelectedChat` setter, which opens a chat on assignment.
+- **Copy → per reply, revealed on hover.** Right-click was rejected: turns are `SelectableTextBlock`,
+ so right-click already belongs to text selection, and a custom `ContextMenu` would work on the
+ padding but not the prose — the same dead-zone failure already fixed once in the sidebar. Fixed a
+ real bug on the way: the header button copied the *latest* reply regardless of which one you were
+ looking at.
+- **Retry → "Try again" on the unanswered message.** A failed or stopped send deletes its empty
+ reply, so the 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
+ — the transcript is the only place the text still exists. "Regenerate on the last reply" was
+ rejected: `RetryAsync` appends a new pair rather than replacing one, so the label would lie, and
+ real regeneration needs engine support.
+- **Model picker → composer hint row**, left-aligned to the reading column. It is an input to the
+ next send, not a toolbar setting. Not the `⋯` menu, which is per-app rather than per-message.
+
+Open: turn-action buttons are 34px per the house floor, while `CodeBlockView`'s Copy is 26px. The
+two should agree; which way is a judgement call and is not decided here.
+
+### Composer re-anchored to the reading column — 2026-08-04
+
+The composer lives in the root grid, so unlike the transcript it never sat inside the sidebar's
+column and never inherited its offset. Left-aligned, its content started at x=24 while replies start
+at x=260 — a 236px gap, under the chat list rather than under the conversation.
+
+Fixed with a gutter `Panel` bound to `#Scroller.Bounds.X`, so the composer tracks the transcript's
+own offset rather than recomputing the sidebar width. It follows a splitter drag and collapses to 0
+when the chat list is hidden, and the two cannot disagree. Verified via UI Automation: composer
+input and transcript card both at 260 logical.
+
+The 720 reading cap stays on both surfaces. Deliberately not full-bleed: a full-width input would
+reflow every message on send, would become the largest object on screen while carrying the least
+content, and would drift the Send button ~930px from the last character typed.
+
+### Inline formatting in the GUI — 2026-08-03
+
+Bold, italic, bold-italic, inline code and links render instead of being flattened to plain text.
+Markdig does the parsing in both hosts, so the console and the GUI cannot drift on what counts as
+emphasis.
+
+- `MarkdownBlock` keeps a list of `InlineSpan` runs alongside its plain `Text`. `Text` stays the
+ source for copy and export — a pasted transcript should not carry styling the destination cannot
+ honour.
+- Styles are flags and are OR-ed down the tree, because markdown nests: `***x***` is bold wrapping
+ italic, and reassigning would drop the outer one.
+- Run colours bind as `DynamicResource` rather than resolving once, so a theme switch repaints an
+ already-displayed transcript.
+- List markers are their own span, so `- **Done**` does not embolden the bullet.
+
+### Local AOT publish repaired — 2026-08-03
+
+Two independent faults, both now fixed; `dotnet publish` with no wrapper and no extra flags
+produces the AOT binary again. Details in [`docs/06`](docs/06-build-and-distribute.md).
+
+1. The C++ workload was never installed. A `link.exe` from an unrelated Visual Studio made it look
+ present, but it shipped with no import libraries and no Windows SDK. Installed VS 2022 Build
+ Tools with `Microsoft.VisualStudio.Workload.VCTools --includeRecommended`.
+2. `vswhere.exe` was not on `PATH`. `vcvarsall.bat` calls it internally, recovers when it fails,
+ but prints to stderr on the way — and MSBuild merges stderr into the probe's captured output, so
+ the compiler spliced the error text into the linker path and ran a command starting
+ `'vswhere.exe' is not recognized...`. Adding the Installer directory to `PATH` silences it.
+
+`publish\OpenKey.exe` (11.6 MB) and `publish\OpenKeyApp.exe` (26.5 MB) are current, both carrying
+the new icon, both launch-tested.
+
+### Visual system, control states, identity — 2026-08-03
+
+- **Palette rebuilt.** Every previous token was an unmodified Tailwind swatch; the new ramps are
+ chosen for this app. Governing rule across all four themes: chrome recedes, content advances,
+ overlays float — the old palette did the reverse, which is why the window never resolved into
+ planes. Light gained four real surfaces ending in true white (the old `Surface` and
+ `SurfaceRaised` were 3 L\* apart — a 1.07:1 step, invisible), dark moved off navy so the accent
+ has somewhere to stand, and `mono` gained six separated luminance steps (it previously collapsed
+ Body, Brand, Ok and CodeType onto one identical grey, proving nothing).
+- **Control interaction states.** New `Styles/Controls.axaml` at *application* scope — the button
+ classes used to live in `MainWindow.Styles`, so both dialogs rendered as raw Fluent, which was
+ the single largest reason the app looked half-styled. Hover, pressed, disabled, and a
+ keyboard-only `:focus-visible` ring for every button, list row, and the model dropdown.
+- Two verified bugs behind "the buttons feel placed, not designed": Fluent's `ControlTheme` sets
+ the template part directly, so the primary button lost its brand colour on hover, and the
+ destructive menu item lost its red exactly on hover. Every colour state now targets the part.
+- **Resizable sidebar** — `GridSplitter`, 180–360px, hairline that lights up on hover, no layout
+ shift while dragging. Width is not persisted yet (see *Next up*).
+- **Turn differentiation** — your message on a raised card, the reply on the page. Both speakers
+ previously used the accent colour, so it meant "a name is here" rather than "this is the AI".
+- **Reading column** capped at 720px, explicit line heights, 34px minimum hit targets. The status
+ dismiss button was ~14px — the smallest control in the app, and the one you press when something
+ has already gone wrong.
+- **Identity**: the mark, the wordmark lockup, and `assets/openkey.ico` on both executables. Not a
+ key — 1Password, Bitwarden, KeePass and Keeper all own key marks in the same 16px taskbar slot,
+ so a key would read "password manager", and it inverts the promise besides. Generated by
+ `tools/make-icon.ps1`; see [`docs/06`](docs/06-build-and-distribute.md).
+- **Your name** — `/name` in the console, **⋯ → Your name…** in the GUI. Not asked at first run:
+ that screen already asks for a key, and the Windows account name is right almost every time.
+ Display only, never sent to a model, never in an export. `userName` in
+ [`docs/05`](docs/05-persistence-and-reset.md).
+
### Tier 2 and Tier 3 backlog, plus roadmap Quick Wins — 2026-08-03
- **`/new`** — start a fresh conversation, keeping the key. Was the most conspicuous missing verb:
@@ -89,12 +230,51 @@ 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.
+- Window size and position persistence, **and sidebar width**. Deliberately skipped: `config.json`'s
+ shape is a contract surface documented in `docs/05`, it is shared with the console host which has
+ no sidebar, and GUI-only layout values do not belong in it without a decision. The likely answer
+ is a separate `window.json` that is explicitly *not* a contract surface — but that still changes
+ the `%APPDATA%\OpenKey\` layout, so it needs sign-off first.
+- Social preview card (`assets/openkey-social.png`, 1280×640) for the GitHub repo settings. The
+ mark and colour are settled; only the export remains. Not embedded in the README — a large
+ centred logo above a heading GitHub already renders reads as self-important.
- Per-message copy buttons, in addition to the toolbar's copy-last and the per-code-block copy.
+### Project rename — name to be decided
+
+**Highly probable**: "OpenKey" is being replaced. Nothing to do until the new name is chosen, but
+recording the surface area now, because it is much wider than a find-and-replace and some of it
+cannot be renamed silently.
+
+Code and build:
+- `OpenKey.Core`, `OpenKey.Windows`, `OpenKey.Providers.OpenRouter`, `OpenKey.Gui` project and
+ assembly names; `OpenKeyApp` is the GUI `AssemblyName` and appears in `avares://` URIs
+- Root namespaces, `InternalsVisibleTo`, the three test projects
+- `OpenKey.sln`, `Directory.Build.props`, both `ApplicationIcon` paths, `app.manifest`
+- `OpenKeyConfig`, `OpenKeyJsonContext`, `GuiTheme`, and the `OpenKey AI` speaker label
+
+**Contract surfaces — these carry a migration cost, not just a rename:**
+- `%APPDATA%\OpenKey\` is the storage root. Renaming it strands every existing user's key, chats
+ and settings unless a migration copies the old directory forward — the same shape as the
+ `session.json` → `chats\` migration already in `JsonChatStore`.
+- `key.bin` is DPAPI-encrypted per Windows account, so it **can** be moved by a local migration but
+ can never be regenerated from elsewhere. Get this right or people lose their key.
+- `docs/05-persistence-and-reset.md` documents the layout and is normative.
+
+Outside the repo, and not all of it under our control:
+- GitHub repo name and every `corecompiled/OpenKey` URL in docs, `UpdateChecker`'s hardcoded
+ releases endpoint, CI and release workflows
+- The Scoop manifest `packaging/scoop/openkey.json`, its package id and the `openkey` bin alias —
+ renaming breaks existing installs' update path
+- Published release titles and assets; `SECURITY.md`, `LICENSE`, `CONTRIBUTING.md`
+- The mark itself is a stylised **K** — see the identity notes in **Done** above. A new name
+ starting with a different letter invalidates the logo's whole rationale, so the rename and the
+ mark need deciding together, not in sequence.
+
+Sequence when it happens: pick the name → decide the storage migration → rename code and docs →
+rename the repo and fix the update endpoint → republish Scoop. The update checker is the sharp edge:
+if it is renamed before a release exists under the new name, installed copies stop seeing updates.
+
### Tier 4 — providers
13. **Anthropic provider** — see [roadmap Phase 5](docs/07-roadmap.md#phase-5--claude-code-provider-integration).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07c56ff..7020dd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
Notable changes to OpenKey. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [0.4.0] — 2026-08-04
### Added
@@ -11,6 +11,76 @@ versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
with the link — nothing is ever downloaded or installed for you. Turn it off with
`"checkForUpdates": false` in `config.json`; see `SECURITY.md` for exactly what the check sends,
which is nothing beyond the request itself.
+- **OpenKey has an icon.** It appears in Explorer, the taskbar, the Start menu and alt-tab, on both
+ the console and the desktop app.
+- **Choose what OpenKey calls you.** `/name Sam` in the console, or **⋯ → Your name…** in the app.
+ It defaults to your Windows account name, so it never interrupts you to ask. The name is a label
+ on your screen only — it is never sent to a model, and exports still say "You", so a transcript
+ you share does not carry a name you did not choose to put in it.
+- **The chat list is resizable.** Drag its edge; it remembers nothing between launches yet.
+- **Your messages sit in a bubble on the right** that fits the text, instead of stretching across
+ the conversation. A wide code block in a reply no longer inflates every message you sent.
+- **The message box spans the conversation** instead of stopping short of the right edge, which was
+ most obvious on a maximised window with an empty chat.
+- Your messages now reach the right edge of the conversation, level with the message box. They were
+ aligned to the right of the reading column rather than the pane, so they stopped short of it.
+- **A waiting indicator while the model thinks** — three dots that pulse in turn, shown from the
+ moment you send until the first word arrives. The blinking caret now only appears once text is
+ actually coming in, so an empty caret can no longer look like a stall.
+- **`OPENKEY_HOME`** points OpenKey at a different folder for its files. Mainly for trying things
+ out without touching your real conversations; your key is still encrypted for your Windows
+ account wherever the folder lives.
+- **Replies now show bold, italic and inline code.** Links appear as links instead of the address
+ being dumped in brackets after the text.
+- **The toolbar is quieter.** New chat moved to the top of the chat list, where the chat it creates
+ appears — it still shows in the toolbar when the list is hidden, so Ctrl+N always has a button.
+ Copy moved onto each reply. The model picker moved down beside the message box, next to the Send
+ it applies to. Export stayed put.
+- **"Try again" appears on your message when a reply fails or you stop it**, so a send that went
+ nowhere can be repeated without retyping.
+
+### Changed
+
+- **The light theme reads properly now.** Buttons, captions, hints and timestamps were all sitting
+ at the bare minimum contrast against a heavy beige, so the whole interface looked faint even
+ though the message text itself was fine. Those labels are ~50% stronger and the backgrounds lost
+ most of their yellow.
+- **Code blocks stand out from the page**, in every theme. In `mono` especially the snippet had
+ almost no background of its own, so it floated on the page; it now has a visible panel and edge,
+ and its comments, strings and keywords are properly separated.
+- **The chat list runs the full height of the window.** The message box used to stretch underneath
+ it, leaving an empty corner at the bottom left.
+- **The colours were redone, all four themes.** The light theme in particular was flat — its
+ surfaces were so close together that the window read as one sheet held together by hairlines,
+ which is what "too light" was actually describing. Every theme now has clearly separated
+ foreground and background layers.
+- **Buttons respond to being pressed.** Hover, press and focus states throughout, including in the
+ two dialogs, which previously used none of the app's styling at all.
+- **Your messages sit on a card, replies sit on the page**, so you can find your own question in a
+ long conversation at a glance.
+- Replies are held to a comfortable reading width instead of stretching the full window, and text
+ has more room to breathe.
+
+### Fixed
+
+- The primary button lost its colour when you hovered over it.
+- "Erase everything…" stopped looking dangerous at the exact moment you pointed at it.
+- The confirmation dialog ignored your theme and always showed dark colours, with a dark red
+ "Close" button on the About box.
+- The status bar showed dark-theme colours in the light theme, and put colour into `mono`, whose
+ entire purpose is not to have any.
+- The button for dismissing an error message was the smallest control in the app.
+- "Try again" added a second copy of your message instead of replacing the one that failed.
+- The Copy button inside a code block was a different size from the one on a reply.
+- Hiding the chat list left an empty strip where it had been, instead of giving the space back to
+ the conversation.
+- A command typed or pasted with a leading space — or an invisible character left behind by a paste
+ from a file or a web page — was not recognised, and got sent to the model as a message instead.
+- Switching theme left the status bar's colour from the previous theme until the next message.
+- The message box and the status bar sat 236px to the left of the conversation, tucked under the
+ chat list, instead of lining up with the replies above them.
+- Copy copied the *most recent* reply rather than the one you were looking at, so copying an older
+ answer silently gave you a different message.
## [0.3.0] — 2026-08-03
@@ -160,7 +230,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.3.0...HEAD
+[Unreleased]: https://github.com/corecompiled/OpenKey/compare/v0.4.0...HEAD
+[0.4.0]: https://github.com/corecompiled/OpenKey/releases/tag/v0.4.0
[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
diff --git a/Directory.Build.props b/Directory.Build.props
index b890730..425f76f 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,7 +8,7 @@
truelatestRecommended
- 0.3.0
+ 0.4.0en-US
diff --git a/README.md b/README.md
index 222fa3a..66ea58d 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ Full walkthrough: [`docs/08-user-guide.md`](docs/08-user-guide.md).
| `/models` | Choose which AI model answers you |
| `/model` | Show which model is answering right now |
| `/theme` | Switch colours: default, dark, light, mono |
+| `/name` | Change what OpenKey calls you |
| `/about` | Version, where your data lives, who made it |
| `/cls` | Clear the screen |
| `/help` | List all commands |
diff --git a/assets/openkey.ico b/assets/openkey.ico
new file mode 100644
index 0000000..4d168ea
Binary files /dev/null and b/assets/openkey.ico differ
diff --git a/docs/05-persistence-and-reset.md b/docs/05-persistence-and-reset.md
index 1070cd6..87ed790 100644
--- a/docs/05-persistence-and-reset.md
+++ b/docs/05-persistence-and-reset.md
@@ -133,10 +133,21 @@ See `04-model-rotation.md` § "Persistence of rotation state".
"preferredModels": [],
"theme": "default",
"maxTokens": 2048,
- "checkForUpdates": true
+ "checkForUpdates": true,
+ "userName": null
}
```
+`userName` is what OpenKey calls you on your own messages. `null` — the default — means "use the
+Windows account name", so there is no name prompt at first run: the first run already asks for a
+key, and a second question before anything useful has happened is a tax when the account name is
+right almost every time. Change it with `/name` in the console or **⋯ → Your name…** in the GUI;
+`/name reset` or a blank value clears it.
+
+It is **display only**. It never enters a `ChatMessage`, is never sent to a provider, and never
+appears in an export — exports say `You`, so a shared transcript does not carry a name the author
+did not choose to put in it.
+
`checkForUpdates` governs the single request to `api.github.com` made at launch to see whether a
newer release exists. Notify only — nothing is downloaded or installed automatically, in any phase.
Set it to `false` and OpenKey talks to OpenRouter and nowhere else. See
@@ -145,11 +156,13 @@ Set it to `false` and OpenKey talks to OpenRouter and nowhere else. See
If absent, defaults apply (empty list means rotation chooses freely).
Owned by `IConfigStore` / `JsonConfigStore`. `preferredModels` is how a pinned model is expressed —
-a single entry — so `/models` now survives a restart. `/theme` writes `theme`.
+a single entry — so `/models` now survives a restart. `/theme` writes `theme`; `/name` writes
+`userName`.
The file is meant to be hand-editable, so every field is treated as untrusted on load: blank model
-ids are dropped, `theme` is lower-cased and falls back to `default` if unknown, and `maxTokens`
-outside a sane range reverts to 2048. A corrupt file is renamed to `config.json.broken-` and
+ids are dropped, `theme` is lower-cased and falls back to `default` if unknown, `maxTokens`
+outside a sane range reverts to 2048, and `userName` is trimmed, capped at 32 characters, and
+treated as unset when blank. A corrupt file is renamed to `config.json.broken-` and
defaults apply, exactly as with a corrupt session — preferences are never worth failing a launch
over.
diff --git a/docs/06-build-and-distribute.md b/docs/06-build-and-distribute.md
index 12e0811..e6121a3 100644
--- a/docs/06-build-and-distribute.md
+++ b/docs/06-build-and-distribute.md
@@ -29,6 +29,44 @@ For `win-arm64`, also add `Microsoft.VisualStudio.Component.VC.Tools.ARM64`.
Without it you get *"Platform linker not found"*. **`dotnet build`, `dotnet test` and `dotnet run`
are unaffected** — only publishing needs this.
+A present `link.exe` does not mean the workload is installed: a Visual Studio install can leave a
+compiler stub with no import libraries and no Windows SDK, which fails exactly the same way. Verify
+what the compiler actually probes for rather than looking for the linker:
+
+```powershell
+& "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" `
+ -latest -prerelease -products * `
+ -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
+```
+
+Empty output means the workload is missing, whatever else is on disk. That command is the same
+query `findvcvarsall.bat` in the `microsoft.dotnet.ilcompiler` package runs — note it already
+passes `-prerelease`, so a preview Visual Studio is not the problem.
+
+### Prerequisite: `vswhere.exe` on `PATH`
+
+```
+C:\Program Files (x86)\Microsoft Visual Studio\Installer
+```
+
+Add that directory to `PATH`. Without it, publishing fails with a linker command that begins
+`'vswhere.exe' is not recognized...` **even though the workload is installed correctly** — which
+reads like a missing linker and is not one.
+
+The cause is worth knowing, because nothing about the message points at it. `findvcvarsall.bat`
+calls `vcvarsall.bat`, which looks up `vswhere` on `PATH`; when that fails it prints to stderr and
+carries on, so the script still exits 0 with the right answer on stdout. But the compiler captures
+it with MSBuild's `ConsoleToMSBuild`, which **merges stderr into stdout**, and then takes
+`Split('#')[0]` as the linker directory. That slice is the error text rather than the path, so the
+compiler invokes a command built out of an error message.
+
+Diagnose by running the script directly — it should print exactly two lines, a path ending in `#`
+and a `LIB` list. Any line before those is the fault:
+
+```powershell
+cmd /c "`"$env:USERPROFILE\.nuget\packages\microsoft.dotnet.ilcompiler\10.0.5\build\findvcvarsall.bat`" x64"
+```
+
For Windows on ARM, swap the RID:
```cmd
@@ -57,10 +95,26 @@ All set in `OpenKey.csproj`, not on the command line.
| `SelfContained` | Implied by AOT; stated for clarity. The user needs nothing installed. |
| `RuntimeIdentifiers` | `win-x64;win-arm64`. |
| `InvariantGlobalization=false` | LLM replies are full of non-ASCII text. Costs ICU in the bundle; a deliberate trade. |
+| `ApplicationIcon` | `assets/openkey.ico`, the same file for both executables — one product, two front doors. The SDK writes it into the PE's Win32 resource table ahead of the AOT link, so it survives `PublishAot`. |
`IsAotCompatible` is gone — it existed to surface trim/AOT warnings without committing to AOT, and
`PublishAot` implies the same analyzers.
+### The icon is a committed artefact, not a build step
+
+`assets/openkey.ico` is checked in. Nothing in the build generates it, so the publish pipeline
+needs no image toolchain. Regenerate it by hand after a change to the mark or the brand colour:
+
+```powershell
+.\tools\make-icon.ps1
+```
+
+The script uses only `System.Drawing` from the .NET Framework GAC — present on every Windows box,
+nothing to install. It writes seven sizes (16, 20, 24, 32, 48, 64, 256): BMP entries below 256 and
+PNG at 256, which is the layout real icon tooling emits. PNG at every size is legal on Windows 10
+and later but is not universally decodable — `System.Drawing.Icon` refuses such a file outright,
+which is fair warning about other consumers.
+
## AOT, and why the old objection expired
This document used to say AOT was blocked by Spectre.Console's internal reflection. Measured from
@@ -91,27 +145,27 @@ Trimming is not separately enabled: AOT already implies it.
## Versioning
-Edit `Directory.Build.props` at the repo root — version is shared by every project:
+`Directory.Build.props` at the repo root holds the only version in the codebase:
```xml
-0.1.0
-0.1.0+$(GITCOMMIT)
+0.4.0
```
-Print in banner:
+Everything else derives from it. The SDK turns it into `AssemblyInformationalVersion` with a
+`+` suffix, and both surfaces read that attribute rather than carrying a literal — the
+console banner via `Components.Version`, the app header via `MainWindowViewModel.Version`. Both trim
+the suffix, because a commit hash on a banner is noise to the person reading it. So a version bump
+is one line, and the UI cannot disagree with the build.
-```csharp
-var ver = typeof(Program).Assembly
- .GetCustomAttribute()?.InformationalVersion
- ?? "dev";
-AnsiConsole.MarkupLine($"[bold cyan]OpenKey[/] [grey]v{ver}[/]");
-```
+The `.exe` metadata Explorer shows comes from the same property.
+
+SemVer, corrected against what actually shipped:
+- `0.x` — everything so far, including the GUI
+- `1.0.0` — reserved for the first stable release
-SemVer:
-- `1.0.0` — reserved for the first stable release; shipping versions so far are `0.x`
-- `1.1.0` — Phase 1.1 QoL
-- `1.2.0` — Phase 1.2 QoL
-- `2.0.0` — GUI (Phase 2)
+An earlier version of this table mapped releases onto internal phase numbers and claimed the GUI
+would be `2.0.0`. It shipped in `0.3.0`. Phases describe the order work happens in, not the version
+it lands under, and public surfaces never mention them — see the release naming convention above.
## Smoke test checklist (manual, run after every publish)
diff --git a/docs/08-user-guide.md b/docs/08-user-guide.md
index 024d3fc..7223830 100644
--- a/docs/08-user-guide.md
+++ b/docs/08-user-guide.md
@@ -53,6 +53,7 @@ picks up where you left off.
| `/models` | Choose which AI model answers you |
| `/model` | Show which model is answering right now |
| `/theme` | Switch colours: default, dark, light, mono |
+| `/name` | Change what OpenKey calls you |
| `/about` | Version, where your data lives, who made it |
| `/cls` | Clear the screen |
| `/help` | List these commands |
@@ -80,6 +81,15 @@ your key stopped working or you want to switch accounts.
colour entirely — useful for screenshots, high-contrast setups, or if colour is hard to
distinguish. Your choice is remembered. `/theme` on its own shows the current one.
+### `/name`
+
+By default OpenKey labels your messages with your Windows account name, so it never asks. If that
+is not what you want to be called, `/name Sam` changes it, and `/name reset` puts it back. `/name`
+on its own shows the current one.
+
+It only changes the label on screen. Your name is never sent to a model, and exports say `You` —
+so a transcript you share does not carry a name you did not choose to put in it.
+
### `/models`
Lists every free model, with its context size — roughly how much conversation it can hold at once.
diff --git a/src/OpenKey.Core/Engine/ChatEngine.cs b/src/OpenKey.Core/Engine/ChatEngine.cs
index 97a1e5d..2d41389 100644
--- a/src/OpenKey.Core/Engine/ChatEngine.cs
+++ b/src/OpenKey.Core/Engine/ChatEngine.cs
@@ -23,7 +23,7 @@ public sealed class ChatEngine
private readonly IChatProvider _provider;
private readonly IRotationPolicy _rotation;
private readonly IModelCatalog _catalog;
- private readonly ISessionStore _sessions;
+ private readonly IChatStore _chats;
private readonly IConfigStore _config;
private readonly ITokenCounter _tokens;
@@ -33,14 +33,14 @@ public ChatEngine(
IChatProvider provider,
IRotationPolicy rotation,
IModelCatalog catalog,
- ISessionStore sessions,
+ IChatStore chats,
IConfigStore config,
ITokenCounter? tokens = null)
{
_provider = provider;
_rotation = rotation;
_catalog = catalog;
- _sessions = sessions;
+ _chats = chats;
_config = config;
_tokens = tokens ?? new HeuristicTokenCounter();
PreferredModelId = config.Current.PinnedModel;
@@ -72,26 +72,83 @@ public string? PreferredModelId
public async Task ResumeAsync(CancellationToken ct)
{
- var snap = await _sessions.LoadAsync(ct);
- if (snap is null)
+ var id = await _chats.MostRecentIdAsync(ct);
+ if (id is null)
{
- ResetTurnsToSystemOnly();
+ StartNewChat();
return;
}
+ await OpenChatAsync(id, ct);
+ }
+
+ ///
+ /// Starts a conversation without touching the previous one. It gets an id on first save, so an
+ /// empty chat nobody used never reaches disk.
+ ///
+ public Task NewSessionAsync(CancellationToken ct)
+ {
+ StartNewChat();
+ return Task.CompletedTask;
+ }
+
+ /// The conversation currently open, or null before anything has been said.
+ public string? CurrentChatId { get; private set; }
+
+ public string CurrentChatTitle { get; private set; } = Chat.Untitled;
+
+ public Task> ListChatsAsync(CancellationToken ct) => _chats.ListAsync(ct);
+
+ public async Task OpenChatAsync(string id, CancellationToken ct)
+ {
+ var chat = await _chats.LoadAsync(id, ct);
+ if (chat is null) return false;
+
_turns.Clear();
- _turns.AddRange(snap.Turns);
+ _turns.AddRange(chat.Turns);
if (_turns.Count == 0 || _turns[0].Role != ChatMessage.SystemRole)
_turns.Insert(0, new ChatMessage(ChatMessage.SystemRole, DefaultSystemPrompt));
+
+ CurrentChatId = chat.Id;
+ CurrentChatTitle = chat.Title;
+ _createdAt = chat.CreatedAt;
+ return true;
}
- public Task NewSessionAsync(CancellationToken ct)
+ public async Task DeleteChatAsync(string id, CancellationToken ct)
+ {
+ await _chats.DeleteAsync(id, ct);
+
+ // Deleting the chat you are looking at should leave you somewhere sensible, not staring at
+ // a conversation that no longer exists.
+ if (CurrentChatId != id) return;
+
+ var next = await _chats.MostRecentIdAsync(ct);
+ if (next is null || !await OpenChatAsync(next, ct)) StartNewChat();
+ }
+
+ public async Task RenameChatAsync(string id, string title, CancellationToken ct)
+ {
+ var chat = await _chats.LoadAsync(id, ct);
+ if (chat is null) return;
+
+ var clean = string.IsNullOrWhiteSpace(title) ? Chat.Untitled : title.Trim();
+ if (clean.Length > Chat.MaxTitleLength) clean = clean[..Chat.MaxTitleLength];
+
+ await _chats.SaveAsync(chat with { Title = clean }, ct);
+ if (CurrentChatId == id) CurrentChatTitle = clean;
+ }
+
+ private void StartNewChat()
{
ResetTurnsToSystemOnly();
- _sessions.Clear();
- return Task.CompletedTask;
+ CurrentChatId = null;
+ CurrentChatTitle = Chat.Untitled;
+ _createdAt = DateTimeOffset.UtcNow;
}
+ private DateTimeOffset _createdAt = DateTimeOffset.UtcNow;
+
///
/// 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
@@ -106,9 +163,8 @@ public async Task RestoreTurnsAsync(IReadOnlyList turns, Cancellati
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);
+ CurrentChatId ??= IChatStore.NewId();
+ await PersistAsync(ActiveModel?.Id ?? string.Empty, ct);
}
public async IAsyncEnumerable SendAsync(
@@ -309,8 +365,20 @@ private async Task CommitTurnAsync(ModelInfo model, string assistantText, Cancel
{
_rotation.MarkSuccess(model.Id);
_turns.Add(new ChatMessage(ChatMessage.AssistantRole, assistantText));
- await _sessions.SaveAsync(
- new SessionSnapshot(model.Id, DateTimeOffset.UtcNow, _turns.ToArray()),
+ await PersistAsync(model.Id, ct);
+ }
+
+ ///
+ /// Writes the open conversation. The id and title are assigned on the first save, so a chat
+ /// only exists on disk once something was actually said in it.
+ ///
+ private async Task PersistAsync(string modelId, CancellationToken ct)
+ {
+ CurrentChatId ??= IChatStore.NewId();
+ if (CurrentChatTitle == Chat.Untitled) CurrentChatTitle = Chat.TitleFrom(_turns);
+
+ await _chats.SaveAsync(
+ new Chat(CurrentChatId, CurrentChatTitle, modelId, _createdAt, DateTimeOffset.UtcNow, _turns.ToArray()),
ct);
}
diff --git a/src/OpenKey.Core/Storage/IChatStore.cs b/src/OpenKey.Core/Storage/IChatStore.cs
new file mode 100644
index 0000000..532e6f1
--- /dev/null
+++ b/src/OpenKey.Core/Storage/IChatStore.cs
@@ -0,0 +1,67 @@
+using OpenKey.Core.Providers;
+
+namespace OpenKey.Core.Storage;
+
+/// One conversation, as stored. Shape is normative — see docs/05-persistence-and-reset.md.
+public sealed record Chat(
+ string Id,
+ string Title,
+ string ModelId,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset UpdatedAt,
+ IReadOnlyList Turns)
+{
+ /// Longest title kept. Long enough to recognise a chat, short enough for a sidebar.
+ public const int MaxTitleLength = 48;
+
+ public const string Untitled = "New chat";
+
+ ///
+ /// A title taken from the opening message rather than generated by a model: a title is
+ /// cosmetic and renameable, and paying tokens and latency for one on every new chat is a bad
+ /// trade.
+ ///
+ public static string TitleFrom(IEnumerable turns)
+ {
+ var first = turns.FirstOrDefault(t => t.Role == ChatMessage.UserRole)?.Content;
+ if (string.IsNullOrWhiteSpace(first)) return Untitled;
+
+ var flat = first.ReplaceLineEndings(" ").Trim();
+ while (flat.Contains(" ", StringComparison.Ordinal))
+ flat = flat.Replace(" ", " ", StringComparison.Ordinal);
+
+ return flat.Length <= MaxTitleLength ? flat : flat[..(MaxTitleLength - 1)].TrimEnd() + "…";
+ }
+}
+
+///
+/// A chat as it appears in a list. Kept separate from so drawing a sidebar does
+/// not mean reading and parsing every conversation on disk.
+///
+public sealed record ChatSummary(string Id, string Title, DateTimeOffset UpdatedAt, int MessageCount);
+
+///
+/// Conversations on disk. Replaces the single-session store: OpenKey used to keep exactly one
+/// conversation, so starting another meant destroying the previous one.
+///
+public interface IChatStore
+{
+ /// Newest first. Never throws — an unreadable store lists nothing rather than failing a launch.
+ Task> ListAsync(CancellationToken ct);
+
+ Task LoadAsync(string id, CancellationToken ct);
+
+ Task SaveAsync(Chat chat, CancellationToken ct);
+
+ Task DeleteAsync(string id, CancellationToken ct);
+
+ /// The chat to open on launch, or null when there are none.
+ Task MostRecentIdAsync(CancellationToken ct);
+
+ /// Removes every conversation. Used by the reset path.
+ void Clear();
+
+ /// A new, unused identifier. Sortable by time, and safe as a filename.
+ static string NewId() =>
+ $"{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString("N")[..6]}";
+}
diff --git a/src/OpenKey.Core/Storage/IConfigStore.cs b/src/OpenKey.Core/Storage/IConfigStore.cs
index ee30127..066d3c8 100644
--- a/src/OpenKey.Core/Storage/IConfigStore.cs
+++ b/src/OpenKey.Core/Storage/IConfigStore.cs
@@ -10,6 +10,15 @@ namespace OpenKey.Core.Storage;
///
/// Palette name: default, dark, light, or mono.
/// Upper bound on reply length requested from the model.
+///
+/// What to call the person using OpenKey, shown as the label on their own messages. Null or blank
+/// means "use the Windows account name", which is the default and needs no prompt.
+///
+/// Display only. It is never placed in a ChatMessage, never sent to a provider, and
+/// never written into an export — exports say "You", so a shared transcript does not carry a name
+/// the author did not choose to put in it.
+///
+///
///
/// Whether to ask GitHub once at launch if a newer release exists. Notify only — nothing is ever
/// downloaded or installed automatically. This is the only request OpenKey makes to anywhere other
@@ -19,7 +28,8 @@ public sealed record OpenKeyConfig(
IReadOnlyList PreferredModels,
string Theme,
int MaxTokens,
- bool CheckForUpdates = true)
+ bool CheckForUpdates = true,
+ string? UserName = null)
{
public const string DefaultTheme = "default";
public const int DefaultMaxTokens = 2048;
@@ -27,6 +37,36 @@ public sealed record OpenKeyConfig(
public static OpenKeyConfig Default { get; } =
new(Array.Empty(), DefaultTheme, DefaultMaxTokens, CheckForUpdates: true);
+ ///
+ /// The name to show on the user's own messages: their chosen name if they set one, otherwise
+ /// the Windows account name. Resolved here rather than in each host so the console and the GUI
+ /// cannot drift apart on it.
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public string DisplayName =>
+ string.IsNullOrWhiteSpace(UserName) ? Environment.UserName : UserName.Trim();
+
+ ///
+ /// Longest stored name. A name is a label on a transcript line, not a field anyone queries;
+ /// past this the console prompt starts eating the input line.
+ ///
+ public const int MaxUserNameLength = 32;
+
+ ///
+ /// Blank clears the override and returns to the Windows account name. Trims and caps, so the
+ /// same rules apply whether the name arrives from a dialog, from /name, or from someone
+ /// hand-editing config.json.
+ ///
+ public OpenKeyConfig WithUserName(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name)) return this with { UserName = null };
+
+ var trimmed = name.Trim();
+ if (trimmed.Length > MaxUserNameLength) trimmed = trimmed[..MaxUserNameLength].TrimEnd();
+
+ return this with { UserName = trimmed };
+ }
+
///
/// The pinned model, or null when rotation is free to choose. A view over
/// , not a stored field — JsonIgnore keeps it out of the
diff --git a/src/OpenKey.Core/Storage/JsonChatStore.cs b/src/OpenKey.Core/Storage/JsonChatStore.cs
new file mode 100644
index 0000000..c270561
--- /dev/null
+++ b/src/OpenKey.Core/Storage/JsonChatStore.cs
@@ -0,0 +1,265 @@
+using System.Text.Json;
+using OpenKey.Core.AppPaths;
+using OpenKey.Core.Providers;
+
+namespace OpenKey.Core.Storage;
+
+///
+/// One JSON file per conversation under chats\, with index.json as a list cache.
+///
+/// A folder of files rather than one large document, so a corrupt write costs a single
+/// conversation instead of all of them — the same reasoning behind quarantining a bad session
+/// rather than refusing to start.
+///
+///
+/// index.json is a cache, never the source of truth. If it is missing, stale or
+/// unreadable it is rebuilt by reading the chat files. That keeps a fast sidebar without creating
+/// a second thing that can disagree with reality.
+///
+///
+public sealed class JsonChatStore : IChatStore
+{
+ private readonly IAppPaths _paths;
+ private bool _migrated;
+
+ public JsonChatStore(IAppPaths paths) => _paths = paths;
+
+ private string Dir => Path.Combine(_paths.RootDir, "chats");
+
+ private string IndexFile => Path.Combine(Dir, "index.json");
+
+ private string FileFor(string id) => Path.Combine(Dir, id + ".json");
+
+ public async Task> ListAsync(CancellationToken ct)
+ {
+ await MigrateIfNeededAsync(ct);
+
+ var index = ReadIndex();
+ if (index is not null) return index;
+
+ var rebuilt = await RebuildIndexAsync(ct);
+ WriteIndex(rebuilt);
+ return rebuilt;
+ }
+
+ public async Task LoadAsync(string id, CancellationToken ct)
+ {
+ await MigrateIfNeededAsync(ct);
+
+ var path = FileFor(id);
+ if (!File.Exists(path)) return null;
+
+ try
+ {
+ await using var stream = File.OpenRead(path);
+ return await JsonSerializer.DeserializeAsync(stream, OpenKeyJsonContext.Default.Chat, ct);
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or NotSupportedException)
+ {
+ Quarantine(path);
+ return null;
+ }
+ }
+
+ public async Task SaveAsync(Chat chat, CancellationToken ct)
+ {
+ // Best-effort, like every store but the key: this runs right after a reply is generated
+ // and before it is shown, so a full disk must not cost the user their answer.
+ try
+ {
+ Directory.CreateDirectory(Dir);
+
+ var path = FileFor(chat.Id);
+ var tmp = path + ".tmp";
+ await using (var stream = File.Create(tmp))
+ {
+ await JsonSerializer.SerializeAsync(stream, chat, OpenKeyJsonContext.Default.Chat, ct);
+ }
+ File.Move(tmp, path, overwrite: true);
+
+ // Index follows the files; if this write fails the next List rebuilds it.
+ var summaries = (await RebuildIndexAsync(ct));
+ WriteIndex(summaries);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+
+ public async Task DeleteAsync(string id, CancellationToken ct)
+ {
+ try
+ {
+ var path = FileFor(id);
+ if (File.Exists(path)) File.Delete(path);
+ WriteIndex(await RebuildIndexAsync(ct));
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+
+ public async Task MostRecentIdAsync(CancellationToken ct)
+ {
+ var all = await ListAsync(ct);
+ return all.Count > 0 ? all[0].Id : null; // ListAsync is newest-first
+ }
+
+ public void Clear()
+ {
+ try
+ {
+ if (Directory.Exists(Dir)) Directory.Delete(Dir, recursive: true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+
+ // ---- migration ---------------------------------------------------------------------
+
+ ///
+ /// Turns a pre-history session.json into the first chat.
+ ///
+ /// The original is renamed rather than deleted, so a failure here can never be the reason
+ /// someone loses the only conversation they had.
+ ///
+ ///
+ private async Task MigrateIfNeededAsync(CancellationToken ct)
+ {
+ if (_migrated) return;
+ _migrated = true;
+
+ try
+ {
+ var legacy = _paths.SessionFile;
+ if (!File.Exists(legacy) || Directory.Exists(Dir)) return;
+
+ SessionSnapshot? snap;
+ await using (var stream = File.OpenRead(legacy))
+ {
+ snap = await JsonSerializer.DeserializeAsync(
+ stream, OpenKeyJsonContext.Default.SessionSnapshot, ct);
+ }
+
+ if (snap?.Turns is { Count: > 0 })
+ {
+ var chat = new Chat(
+ IChatStore.NewId(),
+ Chat.TitleFrom(snap.Turns),
+ snap.ModelId,
+ snap.StartedAt,
+ snap.StartedAt,
+ snap.Turns);
+
+ Directory.CreateDirectory(Dir);
+ await SaveAsync(chat, ct);
+ }
+ else
+ {
+ Directory.CreateDirectory(Dir);
+ }
+
+ File.Move(legacy, legacy + ".migrated", overwrite: true);
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or NotSupportedException)
+ {
+ // A conversation that cannot be migrated is left exactly where it is.
+ }
+ }
+
+ // ---- index -------------------------------------------------------------------------
+
+ private IReadOnlyList? ReadIndex()
+ {
+ if (!File.Exists(IndexFile)) return null;
+
+ try
+ {
+ using var stream = File.OpenRead(IndexFile);
+ var envelope = JsonSerializer.Deserialize(stream, OpenKeyJsonContext.Default.ChatIndex);
+ if (envelope?.Chats is null) return null;
+
+ // A cache that disagrees with the files is worse than no cache. Compare the actual
+ // ids, not just how many there are — an index with the right *number* of wrong
+ // entries would otherwise be trusted.
+ if (!Directory.Exists(Dir)) return null;
+
+ var onDisk = Directory.GetFiles(Dir, "*.json")
+ .Select(Path.GetFileNameWithoutExtension)
+ .Where(name => !string.Equals(name, "index", StringComparison.OrdinalIgnoreCase))
+ .ToHashSet(StringComparer.Ordinal);
+
+ if (envelope.Chats.Count != onDisk.Count) return null;
+ foreach (var summary in envelope.Chats)
+ {
+ if (!onDisk.Contains(summary.Id)) return null;
+ }
+
+ return envelope.Chats;
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or NotSupportedException)
+ {
+ return null;
+ }
+ }
+
+ private async Task> RebuildIndexAsync(CancellationToken ct)
+ {
+ if (!Directory.Exists(Dir)) return Array.Empty();
+
+ var summaries = new List();
+
+ foreach (var path in Directory.GetFiles(Dir, "*.json"))
+ {
+ if (string.Equals(Path.GetFileName(path), "index.json", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ try
+ {
+ await using var stream = File.OpenRead(path);
+ var chat = await JsonSerializer.DeserializeAsync(stream, OpenKeyJsonContext.Default.Chat, ct);
+ if (chat is null) continue;
+
+ summaries.Add(new ChatSummary(
+ chat.Id,
+ string.IsNullOrWhiteSpace(chat.Title) ? Chat.Untitled : chat.Title,
+ chat.UpdatedAt,
+ chat.Turns.Count(t => t.Role != ChatMessage.SystemRole)));
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or NotSupportedException)
+ {
+ Quarantine(path);
+ }
+ }
+
+ summaries.Sort((a, b) => b.UpdatedAt.CompareTo(a.UpdatedAt));
+ return summaries;
+ }
+
+ private void WriteIndex(IReadOnlyList summaries)
+ {
+ try
+ {
+ Directory.CreateDirectory(Dir);
+ var tmp = IndexFile + ".tmp";
+ using (var stream = File.Create(tmp))
+ {
+ JsonSerializer.Serialize(stream, new ChatIndex(summaries), OpenKeyJsonContext.Default.ChatIndex);
+ }
+ File.Move(tmp, IndexFile, overwrite: true);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ }
+ }
+
+ private static void Quarantine(string path)
+ {
+ try { File.Move(path, path + ".broken-" + DateTimeOffset.UtcNow.ToUnixTimeSeconds()); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+}
+
+/// Cache of the chat list. Rebuildable from the chat files; never authoritative.
+public sealed record ChatIndex(IReadOnlyList Chats);
diff --git a/src/OpenKey.Core/Storage/JsonConfigStore.cs b/src/OpenKey.Core/Storage/JsonConfigStore.cs
index 1155746..cee0e0c 100644
--- a/src/OpenKey.Core/Storage/JsonConfigStore.cs
+++ b/src/OpenKey.Core/Storage/JsonConfigStore.cs
@@ -81,6 +81,9 @@ private static OpenKeyConfig Normalize(OpenKeyConfig? config)
? config.MaxTokens
: OpenKeyConfig.DefaultMaxTokens;
- return new OpenKeyConfig(models, theme, maxTokens, config.CheckForUpdates);
+ // WithUserName rather than passing it straight through: this file is hand-editable, so
+ // the name gets the same trim and length cap as one typed into the app.
+ return new OpenKeyConfig(models, theme, maxTokens, config.CheckForUpdates)
+ .WithUserName(config.UserName);
}
}
diff --git a/src/OpenKey.Core/Storage/OpenKeyJsonContext.cs b/src/OpenKey.Core/Storage/OpenKeyJsonContext.cs
index 9c84660..58e4405 100644
--- a/src/OpenKey.Core/Storage/OpenKeyJsonContext.cs
+++ b/src/OpenKey.Core/Storage/OpenKeyJsonContext.cs
@@ -13,6 +13,8 @@ namespace OpenKey.Core.Storage;
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SessionSnapshot))]
+[JsonSerializable(typeof(Chat))]
+[JsonSerializable(typeof(ChatIndex))]
[JsonSerializable(typeof(OpenKeyConfig))]
[JsonSerializable(typeof(JsonModelCatalog.CacheEnvelope))]
[JsonSerializable(typeof(RotationPolicy.StateEnvelope))]
diff --git a/src/OpenKey.Core/Storage/OpenKeyJsonContextAccessor.cs b/src/OpenKey.Core/Storage/OpenKeyJsonContextAccessor.cs
new file mode 100644
index 0000000..88285a7
--- /dev/null
+++ b/src/OpenKey.Core/Storage/OpenKeyJsonContextAccessor.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization.Metadata;
+
+namespace OpenKey.Core.Storage;
+
+///
+/// Exposes the generated type metadata to tests. The context itself stays internal because nothing
+/// outside Core should be serialising these shapes, but tests need to write a fixture file in the
+/// exact format the store will read.
+///
+public static class OpenKeyJsonContextAccessor
+{
+ public static JsonTypeInfo Session => OpenKeyJsonContext.Default.SessionSnapshot;
+
+ public static JsonTypeInfo Chat => OpenKeyJsonContext.Default.Chat;
+}
diff --git a/src/OpenKey.Core/Text/UserInput.cs b/src/OpenKey.Core/Text/UserInput.cs
new file mode 100644
index 0000000..5d79d56
--- /dev/null
+++ b/src/OpenKey.Core/Text/UserInput.cs
@@ -0,0 +1,85 @@
+namespace OpenKey.Core.Text;
+
+///
+/// Cleans a line of typed or pasted input before anything looks at it.
+///
+/// Shared by the hosts rather than reimplemented in each, so a command is recognised on identical
+/// terms everywhere. Pasted text is the case that matters: it arrives from editors, web pages and
+/// files, carrying characters nobody typed and nobody can see.
+///
+///
+/// Every character below is written as an escape rather than pasted literally. A source file full
+/// of invisible characters cannot be reviewed, and the next person to edit it would have no way to
+/// tell them apart — the same class of problem this class exists to fix.
+///
+///
+public static class UserInput
+{
+ private const char ByteOrderMark = '\uFEFF';
+
+ ///
+ /// Strips invisible characters that would otherwise sit in front of a command and stop it being
+ /// recognised, then trims trailing space.
+ ///
+ /// The concrete failure: a UTF-8 file read with its byte-order mark intact yields a line whose
+ /// first character is U+FEFF, so /help does not start with / and is sent to the
+ /// model as a message instead of running. A leading space does the same thing and is far easier
+ /// to hit — command detection ran on the raw string, so " /help" was never a command
+ /// either.
+ ///
+ ///
+ public static string Normalize(string? raw)
+ {
+ var text = StripInvisible(raw);
+
+ // Both classes in one pass, because they interleave: a paste can easily start with a BOM,
+ // then a couple of spaces, then a zero-width space. Consuming only whitespace here would
+ // stop at that zero-width character and leave it in front of the slash — which is the exact
+ // bug this method exists to prevent.
+ var start = 0;
+ while (start < text.Length && (char.IsWhiteSpace(text[start]) || IsZeroWidth(text[start])))
+ start++;
+
+ return text[start..].TrimEnd();
+ }
+
+ ///
+ /// Removes characters that render as nothing, and leaves every visible one — including leading
+ /// spaces — exactly where it was.
+ ///
+ /// This is the right choice for a message body, where is not: the GUI
+ /// composer accepts multi-line input, so trimming the front would silently eat the indentation
+ /// of the first line of a pasted code block.
+ ///
+ ///
+ public static string StripInvisible(string? raw)
+ {
+ if (string.IsNullOrEmpty(raw)) return string.Empty;
+
+ // U+FEFF is removed everywhere, not just at the start: as a byte-order mark it is an
+ // encoding artifact, and its original zero-width-no-break-space meaning has been deprecated
+ // in favour of U+2060, so it never carries intent in a chat message.
+ var text = raw.Contains(ByteOrderMark)
+ ? raw.Replace(ByteOrderMark.ToString(), string.Empty)
+ : raw;
+
+ // The rest are stripped only from the front. U+200D is load-bearing inside emoji
+ // sequences — removing it throughout would break a family emoji into separate people.
+ var start = 0;
+ while (start < text.Length && IsZeroWidth(text[start])) start++;
+
+ return text[start..];
+ }
+
+ ///
+ /// True for characters that occupy no visual space at all. Ordinary and non-breaking spaces are
+ /// deliberately excluded — they are handled by the trim in , which the
+ /// message path does not apply.
+ ///
+ private static bool IsZeroWidth(char c) =>
+ c is '\u200B' // zero-width space
+ or '\u200C' // zero-width non-joiner
+ or '\u200D' // zero-width joiner
+ or '\u2060' // word joiner
+ or '\u180E'; // Mongolian vowel separator
+}
diff --git a/src/OpenKey.Gui/App.axaml b/src/OpenKey.Gui/App.axaml
index 12929db..2a5b402 100644
--- a/src/OpenKey.Gui/App.axaml
+++ b/src/OpenKey.Gui/App.axaml
@@ -1,31 +1,65 @@
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+ 0 1 2 0 #40000000
+ 0 10 28 0 #73000000
+
+
+ 720
diff --git a/src/OpenKey.Gui/Converters/StatusBrushConverter.cs b/src/OpenKey.Gui/Converters/StatusBrushConverter.cs
index 3dacfe9..9a72acd 100644
--- a/src/OpenKey.Gui/Converters/StatusBrushConverter.cs
+++ b/src/OpenKey.Gui/Converters/StatusBrushConverter.cs
@@ -6,24 +6,51 @@
namespace OpenKey.Gui.Converters;
///
-/// Maps a status severity to its accent colour. Kept in one place for the same reason the console
-/// keeps every colour in Theme: a brush chosen at a call site is how a palette drifts.
+/// Maps a status severity to its accent colour.
+///
+/// Resolved from the active palette rather than hardcoded. It previously held four literal hex
+/// values copied from the dark theme, which meant the status bar showed dark-theme colours in the
+/// light theme — and injected the only four hues into mono, the one palette whose entire
+/// purpose is to have none.
+///
///
public sealed class StatusBrushConverter : IValueConverter
{
- private static readonly SolidColorBrush Info = new(Color.Parse("#22D3EE"));
- private static readonly SolidColorBrush Ok = new(Color.Parse("#4ADE80"));
- private static readonly SolidColorBrush Warn = new(Color.Parse("#FBBF24"));
- private static readonly SolidColorBrush Danger = new(Color.Parse("#F87171"));
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ GuiTheme.Brush((value as StatusKind?) switch
+ {
+ StatusKind.Ok => "Ok",
+ StatusKind.Warn => "Warn",
+ StatusKind.Danger => "Danger",
+ _ => "Brand",
+ });
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException();
+}
+
+///
+/// Maps a status severity to its background tint.
+///
+/// The banner used to paint every severity on SurfaceRaised, which in the light theme is
+/// pure white on a near-white page — a 1.088 step. The banner was effectively invisible and the
+/// only thing distinguishing a deletion from a failure was a 2px line along one edge.
+///
+///
+/// mono has no hue to spend, so its tints are a luminance ladder instead: the more serious
+/// the message, the lighter the panel.
+///
+///
+public sealed class StatusSurfaceConverter : IValueConverter
+{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- (value as StatusKind?) switch
+ GuiTheme.Brush((value as StatusKind?) switch
{
- StatusKind.Ok => Ok,
- StatusKind.Warn => Warn,
- StatusKind.Danger => Danger,
- _ => Info,
- };
+ StatusKind.Ok => "OkSurface",
+ StatusKind.Warn => "WarnSurface",
+ StatusKind.Danger => "DangerSurface",
+ _ => "InfoSurface",
+ });
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
diff --git a/src/OpenKey.Gui/GuiTheme.cs b/src/OpenKey.Gui/GuiTheme.cs
index 124142b..6e9dc48 100644
--- a/src/OpenKey.Gui/GuiTheme.cs
+++ b/src/OpenKey.Gui/GuiTheme.cs
@@ -8,13 +8,25 @@ namespace OpenKey.Gui;
///
/// Applies a palette to the running application.
///
-/// Same governing rule as the console: one accent, one neutral, three signals. Colour carries
-/// meaning, never decoration, and no state is signalled by colour alone — which is what makes the
-/// mono palette a legitimate option rather than a novelty.
+/// Governing rule, uniform across every theme: chrome recedes, content advances, overlays
+/// float. One direction, no exceptions, so the eye learns the structure immediately. The
+/// previous palette did the reverse — header and sidebar sat above the transcript — which
+/// is a large part of why the layout never resolved into planes.
///
///
-/// Themes are shared with the console through config.json, so switching in one and opening
-/// the other keeps your choice. Rendering is per-host; the setting is not.
+/// These values are chosen for this app. The palette they replace was, every single token, 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.
+///
+///
+/// Every pair carrying text has a computed WCAG ratio; body text clears 4.5:1 and most clear 7:1.
+/// mono is the standing proof that no state depends on hue, so it uses separated luminance
+/// steps plus weight and italic. The mono palette it replaces collapsed Body, Brand, Ok and
+/// CodeType onto one identical grey, which proved nothing.
+///
+///
+/// Themes are shared with the console through config.json: the setting travels, the
+/// rendering is per-host.
///
///
internal static class GuiTheme
@@ -44,66 +56,177 @@ public static void Apply(Application app, string? name)
switch (theme)
{
case Light:
- Set(r, "Surface", "#F8FAFC");
- Set(r, "SurfaceRaised", "#EEF2F7");
- Set(r, "CodeSurface", "#F1F5F9");
- Set(r, "Line", "#CBD5E1");
- Set(r, "Body", "#0F172A");
- Set(r, "Muted", "#64748B");
- Set(r, "Brand", "#0E7490");
- Set(r, "Ok", "#15803D");
- Set(r, "Warn", "#B45309");
- Set(r, "Danger", "#B91C1C");
- Set(r, "OnBrand", "#F8FAFC");
- Set(r, "CodeKeyword", "#7C3AED");
- Set(r, "CodeString", "#047857");
- Set(r, "CodeComment", "#94A3B8");
- Set(r, "CodeNumber", "#B45309");
- Set(r, "CodeType", "#0E7490");
+ // Four real planes ending in true white, warm but only just.
+ //
+ // Two rounds of feedback shaped this. The first version was flat — Surface and
+ // SurfaceRaised sat 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: the
+ // surfaces read dingy, and every secondary label measured 5.0:1 against them, which
+ // is AA but is also the floor, and it applied to *most of the chrome* — buttons,
+ // captions, hints, timestamps. Body text was never the problem at 15:1.
+ //
+ // So: keep the plane separation (1.175 sunken-to-surface), drop most of the yellow,
+ // and pull Muted to 7.2:1 so a secondary label is quiet rather than faint.
+ Set(r, "SurfaceSunken", "#E8E4DD");
+ Set(r, "Surface", "#F8F6F3");
+ Set(r, "SurfaceRaised", "#FFFFFF");
+ Set(r, "SurfaceOverlay", "#FFFFFF");
+ Set(r, "CodeSurface", "#EDE9E2");
+ Set(r, "Line", "#DDD8D0");
+ Set(r, "LineStrong", "#BEB8AE");
+ Set(r, "LineControl", "#6F685E");
+ Set(r, "Body", "#1B1916");
+ Set(r, "Muted", "#4D473F");
+ Set(r, "Brand", "#4B45C9");
+ Set(r, "BrandHover", "#5A54DA");
+ Set(r, "BrandPressed", "#3B36A6");
+ Set(r, "OnBrand", "#FFFFFF");
+ Set(r, "Ok", "#146039");
+ Set(r, "Warn", "#7A4A08");
+ Set(r, "Danger", "#9E2620");
+ // Tinted surfaces, one per severity. The status banner used to paint every message
+ // on SurfaceRaised, which in this theme is pure white on a near-white page — a
+ // 1.088 step, so the banner was invisible and only a 2px accent line carried the
+ // meaning. These also give the destructive button a rest state that is not a solid
+ // red slab, which beside neutral verbs would train people to ignore it.
+ Set(r, "DangerSurface", "#F8E1DF");
+ Set(r, "InfoSurface", "#E6E4F6");
+ Set(r, "OkSurface", "#DCEFE2");
+ Set(r, "WarnSurface", "#FAE8CE");
+ Set(r, "DangerHover", "#9E2620");
+ Set(r, "DangerPressed", "#821D18");
+ Set(r, "OnDanger", "#FFFFFF");
+ Set(r, "BrandDisabled", "#DEDCF0");
+ Set(r, "SelectionUnfocused", "#E6E2DA");
+ Set(r, "Hover", "#FFFFFF");
+ Set(r, "Pressed", "#DFDAD1"); // on a light ground, pushed reads as *less* light
+ Set(r, "Selection", "#DCD9EC");
+ Set(r, "Focus", "#4B45C9");
+ Set(r, "CodeText", "#24211D");
+ Set(r, "CodeKeyword", "#4A41B4");
+ Set(r, "CodeString", "#17663F");
+ Set(r, "CodeComment", "#5E5648");
+ Set(r, "CodeNumber", "#7E4E0D");
+ Set(r, "CodeType", "#0B5566");
+ // White is the ceiling in light, so menus and dialogs cannot separate by tone.
+ // The shadow is load-bearing here, not decoration.
+ SetShadow(r, "ShadowSoft", "0 1 2 0 #14000000");
+ SetShadow(r, "ShadowOverlay", "0 8 24 0 #26000000");
break;
case Mono:
- // No hue at all. Everything must stay legible, which is the standing test that
- // meaning never depended on colour in the first place.
- Set(r, "Surface", "#101010");
- Set(r, "SurfaceRaised", "#1B1B1B");
- Set(r, "CodeSurface", "#161616");
- Set(r, "Line", "#3A3A3A");
- Set(r, "Body", "#E8E8E8");
- Set(r, "Muted", "#9A9A9A");
- Set(r, "Brand", "#E8E8E8");
- Set(r, "Ok", "#E8E8E8");
- Set(r, "Warn", "#C8C8C8");
+ // Achromatic on purpose. The six code roles are six separated luminance steps;
+ // keyword takes SemiBold and comment italic, because the top two steps are too
+ // close to separate on lightness alone. Weight and style are the legitimate
+ // substitute for hue.
+ Set(r, "SurfaceSunken", "#0B0B0B");
+ Set(r, "Surface", "#141414");
+ Set(r, "SurfaceRaised", "#1D1D1D");
+ Set(r, "SurfaceOverlay", "#262626");
+ // The code ramp is re-spaced and the block is pushed further from the page.
+ // It previously sat 1.048 against Surface — no visible plane at all, so a snippet
+ // did not read as a block — while three of the six roles clustered at the top of
+ // the luminance range and comments sat at 4.7. Six greys is the most this palette
+ // can carry, so the steps are now even and weight and italic do the rest.
+ Set(r, "CodeSurface", "#060606");
+ Set(r, "Line", "#2A2A2A");
+ Set(r, "LineStrong", "#3F3F3F");
+ Set(r, "LineControl", "#6E6E6E");
+ Set(r, "Body", "#E4E4E4");
+ Set(r, "Muted", "#A2A2A2");
+ // Severity is a luminance ladder here, not a set of hues: Ok < Warn < Brand <
+ // Danger, each step at least 1.25x the last. Brand sat 1.05:1 from Danger before,
+ // which is indistinguishable — an accent and an alarm that look identical is
+ // exactly the failure mono exists to rule out.
+ Set(r, "Brand", "#E0E0E0");
+ Set(r, "BrandHover", "#F5F5F5");
+ Set(r, "BrandPressed", "#C4C4C4");
+ Set(r, "OnBrand", "#0B0B0B");
+ Set(r, "Ok", "#8E8E8E");
+ Set(r, "Warn", "#C6C6C6");
Set(r, "Danger", "#FFFFFF");
- Set(r, "OnBrand", "#101010");
- Set(r, "CodeKeyword", "#FFFFFF");
- Set(r, "CodeString", "#C8C8C8");
- Set(r, "CodeComment", "#7A7A7A");
- Set(r, "CodeNumber", "#C8C8C8");
- Set(r, "CodeType", "#E8E8E8");
+ Set(r, "DangerSurface", "#363636");
+ Set(r, "InfoSurface", "#1E1E1E");
+ Set(r, "OkSurface", "#242424");
+ Set(r, "WarnSurface", "#2C2C2C");
+ Set(r, "DangerHover", "#FFFFFF");
+ Set(r, "DangerPressed", "#E0E0E0");
+ Set(r, "OnDanger", "#0B0B0B");
+ Set(r, "BrandDisabled", "#3A3A3A");
+ Set(r, "SelectionUnfocused", "#1C1C1C");
+ Set(r, "Hover", "#202020");
+ Set(r, "Pressed", "#2B2B2B");
+ Set(r, "Selection", "#242424");
+ Set(r, "Focus", "#FFFFFF");
+ Set(r, "CodeText", "#C0C0C0");
+ Set(r, "CodeKeyword", "#EEEEEE");
+ Set(r, "CodeString", "#A3A3A3");
+ Set(r, "CodeComment", "#888888");
+ Set(r, "CodeNumber", "#D3D3D3");
+ Set(r, "CodeType", "#E0E0E0");
+ SetShadow(r, "ShadowSoft", "0 1 2 0 #40000000");
+ SetShadow(r, "ShadowOverlay", "0 10 28 0 #73000000");
break;
- default: // default and dark are the same palette; "dark" exists so the name works
- Set(r, "Surface", "#0F172A");
- Set(r, "SurfaceRaised", "#1E293B");
- Set(r, "CodeSurface", "#0B1220");
- Set(r, "Line", "#334155");
- Set(r, "Body", "#E2E8F0");
- Set(r, "Muted", "#94A3B8");
- Set(r, "Brand", "#22D3EE");
- Set(r, "Ok", "#4ADE80");
- Set(r, "Warn", "#FBBF24");
- Set(r, "Danger", "#F87171");
- Set(r, "OnBrand", "#04121A");
- Set(r, "CodeKeyword", "#C4B5FD");
- Set(r, "CodeString", "#86EFAC");
- Set(r, "CodeComment", "#64748B");
- Set(r, "CodeNumber", "#FCD34D");
- Set(r, "CodeType", "#7DD3FC");
+ default:
+ // Neutral dark rather than navy. The old surface was slate-900 (b* ≈ -16) with a
+ // cyan accent only ~40° away in hue — accent and ground from one family, so the
+ // accent vibrated instead of separating. Iris on a near-neutral ground gives the
+ // accent somewhere to stand.
+ Set(r, "SurfaceSunken", "#0E1014");
+ Set(r, "Surface", "#161920");
+ Set(r, "SurfaceRaised", "#1E222B");
+ Set(r, "SurfaceOverlay", "#262B36");
+ Set(r, "CodeSurface", "#0A0C10");
+ Set(r, "Line", "#272C36");
+ Set(r, "LineStrong", "#3A4150");
+ Set(r, "LineControl", "#6A7283");
+ Set(r, "Body", "#E6E9EF");
+ Set(r, "Muted", "#A8B1C0");
+ Set(r, "Brand", "#9FB1FF");
+ Set(r, "BrandHover", "#B6C3FF");
+ Set(r, "BrandPressed", "#8697EE");
+ Set(r, "OnBrand", "#0E1120");
+ Set(r, "Ok", "#5FD3A0");
+ Set(r, "Warn", "#E7B45C");
+ Set(r, "Danger", "#F2786E");
+ Set(r, "DangerSurface", "#3A1F20");
+ Set(r, "InfoSurface", "#1E2340");
+ Set(r, "OkSurface", "#14302A");
+ Set(r, "WarnSurface", "#332A1A");
+ Set(r, "DangerHover", "#F2786E");
+ Set(r, "DangerPressed", "#DC5F55");
+ Set(r, "OnDanger", "#1A0C0B");
+ Set(r, "BrandDisabled", "#2B3142");
+ Set(r, "SelectionUnfocused", "#232733");
+ Set(r, "Hover", "#20252F");
+ Set(r, "Pressed", "#2A3040");
+ Set(r, "Selection", "#232A3D");
+ Set(r, "Focus", "#8FA2FF");
+ Set(r, "CodeText", "#D5DAE3");
+ Set(r, "CodeKeyword", "#A9B7FF");
+ Set(r, "CodeString", "#7FCCA5");
+ Set(r, "CodeComment", "#7B8698");
+ Set(r, "CodeNumber", "#E2B77C");
+ Set(r, "CodeType", "#79C6DA");
+ SetShadow(r, "ShadowSoft", "0 1 2 0 #40000000");
+ SetShadow(r, "ShadowOverlay", "0 10 28 0 #73000000");
break;
}
}
+ ///
+ /// Resolves a token to a brush for code that cannot use DynamicResource — converters,
+ /// and runs built in code-behind.
+ ///
+ public static IBrush Brush(string key) =>
+ Application.Current?.Resources.TryGetResource(key, null, out var value) == true && value is IBrush brush
+ ? brush
+ : Brushes.Gray;
+
private static void Set(IResourceDictionary resources, string key, string hex) =>
resources[key] = new SolidColorBrush(Color.Parse(hex));
+
+ private static void SetShadow(IResourceDictionary resources, string key, string value) =>
+ resources[key] = BoxShadows.Parse(value);
}
diff --git a/src/OpenKey.Gui/OpenKey.Gui.csproj b/src/OpenKey.Gui/OpenKey.Gui.csproj
index 800abc9..ec34aad 100644
--- a/src/OpenKey.Gui/OpenKey.Gui.csproj
+++ b/src/OpenKey.Gui/OpenKey.Gui.csproj
@@ -7,6 +7,11 @@
win-x64;win-arm64falseapp.manifest
+
+
+ ../../assets/openkey.icotrue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenKey.Gui/ViewModels/InlineSpan.cs b/src/OpenKey.Gui/ViewModels/InlineSpan.cs
new file mode 100644
index 0000000..486a743
--- /dev/null
+++ b/src/OpenKey.Gui/ViewModels/InlineSpan.cs
@@ -0,0 +1,34 @@
+namespace OpenKey.Gui.ViewModels;
+
+///
+/// How a run of text within a block is emphasised. Flags, because markdown nests: ***x***
+/// parses as bold wrapping italic, and both have to survive to the renderer.
+///
+[Flags]
+public enum InlineStyle
+{
+ None = 0,
+ Bold = 1,
+ Italic = 2,
+ Code = 4,
+ Link = 8,
+ Strikethrough = 16,
+}
+
+///
+/// A run of text with one style, produced by flattening Markdig's inline tree.
+///
+/// The view model stops here and does not build UI elements: this stays a plain list of records so
+/// the parsing can be tested without starting a window, which is the same split the block model
+/// already uses.
+///
+///
+/// Set only for ; the destination.
+public sealed record InlineSpan(string Text, InlineStyle Style = InlineStyle.None, string? Url = null)
+{
+ public bool IsBold => Style.HasFlag(InlineStyle.Bold);
+ public bool IsItalic => Style.HasFlag(InlineStyle.Italic);
+ public bool IsCode => Style.HasFlag(InlineStyle.Code);
+ public bool IsLink => Style.HasFlag(InlineStyle.Link);
+ public bool IsStruck => Style.HasFlag(InlineStyle.Strikethrough);
+}
diff --git a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs
index 4933fc8..ca09d69 100644
--- a/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs
+++ b/src/OpenKey.Gui/ViewModels/MainWindowViewModel.cs
@@ -6,6 +6,7 @@
using OpenKey.Core.Engine;
using OpenKey.Core.Providers;
using OpenKey.Core.Storage;
+using OpenKey.Core.Text;
using OpenKey.Core.Updates;
using OpenKey.Providers.OpenRouter;
using OpenKey.Windows.OAuth;
@@ -54,7 +55,7 @@ public MainWindowViewModel(
Messages.CollectionChanged += (_, _) => Raise(nameof(IsConversationEmpty));
StopCommand = new RelayCommand(Stop);
- ClearCommand = new RelayCommand(() => _ = ClearConversationAsync());
+ NewChatCommand = new RelayCommand(() => _ = NewChatAsync());
}
public ObservableCollection Messages { get; } = new();
@@ -64,6 +65,34 @@ public MainWindowViewModel(
public ObservableCollection Models { get; } = new();
+ /// Saved conversations, newest first. Drives the sidebar.
+ public ObservableCollection Chats { get; } = new();
+
+ private ChatSummary? _selectedChat;
+
+ ///
+ /// The chat the sidebar highlights. Setting it opens that conversation; assigning the backing
+ /// field directly is how the code reflects a switch without re-triggering one.
+ ///
+ public ChatSummary? SelectedChat
+ {
+ get => _selectedChat;
+ set
+ {
+ if (!Set(ref _selectedChat, value) || value is null) return;
+ if (value.Id == _engine.CurrentChatId) return;
+ _ = OpenChatAsync(value.Id);
+ }
+ }
+
+ private bool _showChats = true;
+
+ public bool ShowChats
+ {
+ get => _showChats;
+ set => Set(ref _showChats, value);
+ }
+
private ModelChoice? _selectedModel;
///
@@ -159,13 +188,8 @@ public async Task InitializeAsync()
NeedsKey = false;
await _engine.ResumeAsync(CancellationToken.None);
- foreach (var turn in _engine.Turns.Where(t => t.Role != ChatMessage.SystemRole))
- {
- Messages.Add(new MessageViewModel(
- turn.Role == ChatMessage.UserRole ? Speaker.You : Speaker.Assistant,
- turn.Content));
- }
-
+ LoadMessagesFromEngine();
+ await RefreshChatsAsync();
await LoadModelsAsync();
// Not awaited: the window is usable immediately, and a new version is never urgent.
@@ -293,20 +317,15 @@ public async Task SendAsync()
{
if (!CanSend) return;
- var text = Draft.TrimEnd();
+ // StripInvisible, not Normalize: this preserves leading whitespace, so the first line of
+ // a pasted code block keeps its indentation.
+ var text = UserInput.StripInvisible(Draft).TrimEnd();
Draft = string.Empty;
Status = null;
- if (_clearedTurns is not null)
- {
- _clearedTurns = null;
- _clearedMessages = null;
- Raise(nameof(CanUndoClear));
- }
+ Messages.Add(new MessageViewModel(Speaker.You, UserName, text));
- Messages.Add(new MessageViewModel(Speaker.You, text));
-
- var reply = new MessageViewModel(Speaker.Assistant) { IsStreaming = true };
+ var reply = new MessageViewModel(Speaker.Assistant, UserName) { IsStreaming = true };
Messages.Add(reply);
var cts = new CancellationTokenSource();
@@ -369,6 +388,7 @@ await Dispatcher.UIThread.InvokeAsync(() =>
cts.Dispose();
IsBusy = false;
Raise(nameof(ActiveModel));
+ RefreshRetryAffordance();
}
}
@@ -378,7 +398,7 @@ await Dispatcher.UIThread.InvokeAsync(() =>
// calls the methods directly.
public System.Windows.Input.ICommand StopCommand { get; }
- public System.Windows.Input.ICommand ClearCommand { get; }
+ public System.Windows.Input.ICommand NewChatCommand { get; }
/// Resends the last message. Goes through SendAsync so a retry takes the same path.
public async Task RetryAsync()
@@ -391,12 +411,36 @@ public async Task RetryAsync()
return;
}
+ // The engine drops a failed turn from its own history, but the transcript still shows the
+ // message — that is deliberate, and it is the only place the text survives once the draft
+ // box is cleared. SendAsync appends a fresh user turn, so without this the message appears
+ // twice: once for the attempt that failed and once for the retry.
+ if (Messages.Count > 0
+ && Messages[^1] is { IsFromUser: true, IsAwaitingReply: true } stale
+ && stale.Text == last)
+ {
+ Messages.Remove(stale);
+ }
+
Draft = last;
await SendAsync();
}
- public string? LastReply =>
- Messages.LastOrDefault(m => m.Speaker == Speaker.Assistant && m.HasText)?.Text;
+ ///
+ /// Marks the trailing user turn as awaiting a reply, which is what shows its "Try again"
+ /// button.
+ ///
+ /// Called explicitly rather than hooked to Messages.CollectionChanged: the user turn is
+ /// added before is set, so a collection-driven refresh would flash the
+ /// button on for the instant between the two.
+ ///
+ ///
+ private void RefreshRetryAffordance()
+ {
+ var last = Messages.Count > 0 ? Messages[^1] : null;
+ foreach (var message in Messages)
+ message.IsAwaitingReply = ReferenceEquals(message, last) && message.IsFromUser && !IsBusy;
+ }
///
/// Renders the conversation as markdown, matching the console's /export. Returns null
@@ -446,6 +490,11 @@ public void SetTheme(string? name)
_config.Save(_config.Current with { Theme = wanted });
Raise(nameof(Theme));
ThemeChanged?.Invoke(wanted);
+
+ // The status accent comes from a converter, which resolves its brush once at bind time and
+ // has no reason to re-run when the palette is swapped underneath it. Without this the bar
+ // keeps the previous theme's colour until the next status message replaces it.
+ Raise(nameof(StatusKind));
}
// ---- about ---------------------------------------------------------------------------
@@ -462,57 +511,103 @@ public void SetTheme(string? name)
public void NotifyStatus(StatusKind kind, string message) => Show(kind, message);
- private IReadOnlyList? _clearedTurns;
- private MessageViewModel[]? _clearedMessages;
+ private void LoadMessagesFromEngine()
+ {
+ Messages.Clear();
+ foreach (var turn in _engine.Turns.Where(t => t.Role != ChatMessage.SystemRole))
+ {
+ Messages.Add(new MessageViewModel(
+ turn.Role == ChatMessage.UserRole ? Speaker.You : Speaker.Assistant,
+ UserName,
+ turn.Content));
+ }
- /// True while a cleared conversation can still be brought back.
- public bool CanUndoClear => _clearedTurns is not null;
+ // A conversation reopened after a failed send still ends on the unanswered message, so the
+ // offer to resend has to survive a restart.
+ RefreshRetryAffordance();
+ }
+
+ /// What OpenKey calls you: your chosen name, or your Windows account name.
+ public string UserName => _config.Current.DisplayName;
///
- /// Clears the conversation, keeping the key.
- ///
- /// This deletes the only conversation OpenKey stores, so it is offered with undo rather than
- /// behind a confirmation. A dialog interrupts everyone every time to guard against a mistake
- /// that is rare; undo costs nothing until the moment it is needed, and then it costs one
- /// click. The button is also labelled "Clear chat" rather than "New chat" — the latter implies
- /// the old conversation is still somewhere, and it is not.
- ///
+ /// Renames you and relabels the existing transcript, so it does not end up addressing you by
+ /// two names. Blank clears the override and returns to the Windows account name.
///
- public async Task ClearConversationAsync()
+ public void SetUserName(string? name)
{
- if (IsBusy) return;
+ _config.Save(_config.Current.WithUserName(name));
- if (Messages.Count == 0)
- {
- Show(StatusKind.Info, "This chat is already empty.");
- return;
- }
+ var resolved = UserName;
+ foreach (var message in Messages) message.SetUserName(resolved);
+ Raise(nameof(UserName));
+ }
- _clearedTurns = _engine.Turns.ToArray();
- _clearedMessages = Messages.ToArray();
+ public async Task RefreshChatsAsync()
+ {
+ var chats = await _engine.ListChatsAsync(CancellationToken.None);
+
+ Chats.Clear();
+ foreach (var c in chats) Chats.Add(c);
+
+ // Assign the field, not the property: the setter opens a chat, and this is only
+ // reflecting which one is already open.
+ _selectedChat = chats.FirstOrDefault(c => c.Id == _engine.CurrentChatId);
+ Raise(nameof(SelectedChat));
+ Raise(nameof(HasChats));
+ }
+
+ public bool HasChats => Chats.Count > 0;
+
+ ///
+ /// Starts a conversation alongside the existing ones. Nothing is destroyed, which is why this
+ /// needs no confirmation and no undo — the previous chat is still in the sidebar.
+ ///
+ public async Task NewChatAsync()
+ {
+ if (IsBusy) return;
await _engine.NewSessionAsync(CancellationToken.None);
Messages.Clear();
+ await RefreshChatsAsync();
+ Status = null;
+ }
- Raise(nameof(CanUndoClear));
- Show(StatusKind.Ok, "Chat cleared. Your key is untouched.");
+ public async Task OpenChatAsync(string id)
+ {
+ if (IsBusy) return;
+
+ if (!await _engine.OpenChatAsync(id, CancellationToken.None))
+ {
+ Show(StatusKind.Warn, "That chat couldn't be opened.");
+ return;
+ }
+
+ LoadMessagesFromEngine();
+ await RefreshChatsAsync();
+ Status = null;
}
- public async Task UndoClearAsync()
+ public async Task DeleteChatAsync(ChatSummary chat)
{
- if (_clearedTurns is null || _clearedMessages is null) return;
+ if (IsBusy) return;
- await _engine.RestoreTurnsAsync(_clearedTurns, CancellationToken.None);
+ await _engine.DeleteChatAsync(chat.Id, CancellationToken.None);
+ LoadMessagesFromEngine();
+ await RefreshChatsAsync();
+ Show(StatusKind.Ok, $"Deleted \"{chat.Title}\".");
+ }
- Messages.Clear();
- foreach (var m in _clearedMessages) Messages.Add(m);
+ public async Task RenameCurrentChatAsync(string title)
+ {
+ if (_engine.CurrentChatId is not { } id) return;
- _clearedTurns = null;
- _clearedMessages = null;
- Raise(nameof(CanUndoClear));
- Show(StatusKind.Ok, "Chat restored.");
+ await _engine.RenameChatAsync(id, title, CancellationToken.None);
+ await RefreshChatsAsync();
}
+ public string CurrentChatTitle => _engine.CurrentChatTitle;
+
public void PinModel(ModelInfo? model)
{
_engine.PreferredModelId = model?.Id;
@@ -542,13 +637,47 @@ public async Task ResetEverythingAsync()
// ---- status --------------------------------------------------------------------------
+ /// How long a confirmation stays before clearing itself.
+ private static readonly TimeSpan StatusLifetime = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Identifies the message currently on screen, so a pending auto-dismiss only ever clears the
+ /// message it was scheduled for. Without it, deleting two chats in quick succession would let
+ /// the first timer wipe the second confirmation early.
+ ///
+ private int _statusToken;
+
private void Show(StatusKind kind, string message)
{
StatusKind = kind;
Status = message;
+
+ var token = ++_statusToken;
+
+ // Confirmations clear themselves; warnings and failures do not. "Chat deleted" has served
+ // its purpose the moment it is read, but a message explaining why a reply failed should
+ // still be there when you look back at it, and it is the only thing on screen carrying the
+ // reason. Those wait to be dismissed or replaced.
+ if (kind is StatusKind.Ok or StatusKind.Info) _ = AutoDismissAsync(token);
}
- public void DismissStatus() => Status = null;
+ private async Task AutoDismissAsync(int token)
+ {
+ await Task.Delay(StatusLifetime);
+
+ // Posted rather than assumed: the delay resumes on a pool thread if the sync context is
+ // ever absent, and this touches a bound property.
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (_statusToken == token) Status = null;
+ });
+ }
+
+ public void DismissStatus()
+ {
+ _statusToken++;
+ Status = null;
+ }
///
/// Same rule as the console: say what happened and what to do next. Raw error-kind names and
diff --git a/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs b/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs
index 12e7502..b0e4403 100644
--- a/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs
+++ b/src/OpenKey.Gui/ViewModels/MarkdownBlock.cs
@@ -23,12 +23,29 @@ public enum BlockKind
/// the console keeps between MarkdownConsoleRenderer and the engine.
///
///
-/// Inline formatting is flattened to text in this version. Code fences and structure carry most of
-/// the readability benefit; inline bold and italic can come later without changing this shape.
+/// Inline formatting is preserved as a list of runs rather than flattened,
+/// so bold, italic, inline code and links survive to the renderer. The emphasis rules deliberately
+/// match the console's — both hosts parse with Markdig, so the two cannot drift.
///
///
-public sealed record MarkdownBlock(BlockKind Kind, string Text, string? Language = null, int Level = 0, int Indent = 0)
+public sealed record MarkdownBlock(
+ BlockKind Kind,
+ string Text,
+ string? Language = null,
+ int Level = 0,
+ int Indent = 0,
+ IReadOnlyList? Spans = null)
{
+ ///
+ /// The block's text split into styled runs. Falls back to one unstyled run, so a block built
+ /// without spans — a code fence, or the verbatim fallback after a parse failure — still renders.
+ ///
+ /// remains the plain-text form and stays the source for copy and export: a
+ /// pasted transcript should not carry styling the destination cannot honour.
+ ///
+ ///
+ public IReadOnlyList Runs => Spans ?? new[] { new InlineSpan(Text) };
+
public bool IsCode => Kind == BlockKind.Code;
public bool IsRule => Kind == BlockKind.Rule;
public bool IsNotCode => Kind != BlockKind.Code && Kind != BlockKind.Rule;
@@ -41,6 +58,18 @@ public sealed record MarkdownBlock(BlockKind Kind, string Text, string? Language
_ => 14,
};
+ ///
+ /// Explicit leading. Avalonia's default is the font's own line spacing, which for Inter at
+ /// 14px is roughly 1.2× — fine for a label, too tight for paragraphs of prose, and the
+ /// clearest single tell of an interface nobody laid out. Headings take a tighter ratio
+ /// because larger type needs proportionally less air to stay one unit.
+ ///
+ public double LineHeight => Kind switch
+ {
+ BlockKind.Heading => Math.Round(FontSize * 1.3),
+ _ => Math.Round(FontSize * 1.55),
+ };
+
public bool IsHeading => Kind == BlockKind.Heading;
public bool IsQuote => Kind == BlockKind.Quote;
@@ -76,7 +105,7 @@ private static void Walk(Block block, List into, int indent)
switch (block)
{
case HeadingBlock h:
- into.Add(new MarkdownBlock(BlockKind.Heading, Inline(h.Inline), Level: h.Level));
+ into.Add(new MarkdownBlock(BlockKind.Heading, Inline(h.Inline), Level: h.Level, Spans: BuildSpans(h.Inline)));
break;
case FencedCodeBlock fenced:
@@ -94,7 +123,7 @@ private static void Walk(Block block, List into, int indent)
foreach (var child in quote)
{
if (child is LeafBlock lb && lb.Inline is not null)
- into.Add(new MarkdownBlock(BlockKind.Quote, Inline(lb.Inline)));
+ into.Add(new MarkdownBlock(BlockKind.Quote, Inline(lb.Inline), Spans: BuildSpans(lb.Inline)));
else
Walk(child, into, indent);
}
@@ -114,8 +143,14 @@ private static void Walk(Block block, List into, int indent)
{
if (first && child is ParagraphBlock p)
{
+ // The marker is a span of its own so it never picks up the emphasis of
+ // the first word — "- **Done**" must not embolden the bullet.
+ var itemSpans = new List { new($"{marker} ") };
+ itemSpans.AddRange(BuildSpans(p.Inline));
+
into.Add(new MarkdownBlock(
- BlockKind.ListItem, $"{marker} {Inline(p.Inline)}", Indent: indent));
+ BlockKind.ListItem, $"{marker} {Inline(p.Inline)}",
+ Indent: indent, Spans: itemSpans));
first = false;
}
else
@@ -132,7 +167,7 @@ private static void Walk(Block block, List into, int indent)
break;
case ParagraphBlock p2:
- into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(p2.Inline)));
+ into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(p2.Inline), Spans: BuildSpans(p2.Inline)));
break;
case ContainerBlock container:
@@ -140,11 +175,96 @@ private static void Walk(Block block, List into, int indent)
break;
case LeafBlock leaf when leaf.Inline is not null:
- into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(leaf.Inline)));
+ into.Add(new MarkdownBlock(BlockKind.Paragraph, Inline(leaf.Inline), Spans: BuildSpans(leaf.Inline)));
break;
}
}
+ ///
+ /// Flattens Markdig's inline tree into styled runs.
+ ///
+ /// Style is threaded down and OR-ed rather than replaced, because markdown nests:
+ /// ***x*** parses as bold wrapping italic, and reassigning would lose the outer one.
+ ///
+ ///
+ private static List BuildSpans(ContainerInline? container)
+ {
+ var spans = new List();
+ if (container is not null) AppendSpans(spans, container, InlineStyle.None, null);
+ return spans;
+ }
+
+ private static void AppendSpans(List into, Inline inline, InlineStyle style, string? url)
+ {
+ switch (inline)
+ {
+ case LiteralInline lit:
+ AddSpan(into, lit.Content.ToString(), style, url);
+ break;
+
+ case CodeInline code:
+ AddSpan(into, code.Content, style | InlineStyle.Code, url);
+ break;
+
+ case EmphasisInline em:
+ {
+ var added = em.DelimiterChar == '~'
+ ? InlineStyle.Strikethrough
+ : em.DelimiterCount >= 2 ? InlineStyle.Bold : InlineStyle.Italic;
+
+ foreach (var child in em) AppendSpans(into, child, style | added, url);
+ break;
+ }
+
+ case LinkInline link:
+ {
+ var target = string.IsNullOrEmpty(link.Url) ? url : link.Url;
+ var linkStyle = target is null ? style : style | InlineStyle.Link;
+
+ var before = into.Count;
+ foreach (var child in link) AppendSpans(into, child, linkStyle, target);
+
+ // A link with no label — or an image, whose alt text may be empty — would otherwise
+ // vanish entirely. Show the URL rather than nothing.
+ if (into.Count == before && target is not null) AddSpan(into, target, linkStyle, target);
+ break;
+ }
+
+ case AutolinkInline auto:
+ AddSpan(into, auto.Url, style | InlineStyle.Link, auto.Url);
+ break;
+
+ case LineBreakInline:
+ AddSpan(into, "\n", style, url);
+ break;
+
+ case ContainerInline container:
+ foreach (var child in container) AppendSpans(into, child, style, url);
+ break;
+ }
+ }
+
+ ///
+ /// Appends a run, merging it into the previous one when they share a style. Markdig emits
+ /// literals in fragments, so without this a plain sentence becomes a dozen runs.
+ ///
+ private static void AddSpan(List into, string text, InlineStyle style, string? url)
+ {
+ if (text.Length == 0) return;
+
+ if (into.Count > 0)
+ {
+ var last = into[^1];
+ if (last.Style == style && last.Url == url)
+ {
+ into[^1] = last with { Text = last.Text + text };
+ return;
+ }
+ }
+
+ into.Add(new InlineSpan(text, style, url));
+ }
+
private static string Inline(ContainerInline? container)
{
if (container is null) return string.Empty;
diff --git a/src/OpenKey.Gui/ViewModels/MessageViewModel.cs b/src/OpenKey.Gui/ViewModels/MessageViewModel.cs
index ef48b96..a17b27b 100644
--- a/src/OpenKey.Gui/ViewModels/MessageViewModel.cs
+++ b/src/OpenKey.Gui/ViewModels/MessageViewModel.cs
@@ -24,10 +24,13 @@ public sealed class MessageViewModel : ObservableObject
private bool _isStreaming;
private string? _modelId;
private TimeSpan _elapsed;
+ private string _userName;
+ private bool _isAwaitingReply;
- public MessageViewModel(Speaker speaker, string text = "")
+ public MessageViewModel(Speaker speaker, string userName, string text = "")
{
Speaker = speaker;
+ _userName = userName;
_text = text;
if (text.Length > 0) RebuildBlocks();
}
@@ -36,14 +39,53 @@ public MessageViewModel(Speaker speaker, string text = "")
public bool IsFromUser => Speaker == Speaker.You;
- public string Header => Speaker == Speaker.You ? Environment.UserName : "OpenKey AI";
+ ///
+ /// True only on the trailing user turn when nothing answered it, which is exactly the state a
+ /// resend applies to. A failed or stopped turn deletes its empty assistant reply, so "the last
+ /// message is still yours" is a reliable signal rather than a guess.
+ ///
+ public bool IsAwaitingReply
+ {
+ get => _isAwaitingReply;
+ set => Set(ref _isAwaitingReply, value);
+ }
+
+ public string Header => Speaker == Speaker.You ? _userName : "OpenKey AI";
+
+ ///
+ /// Renaming yourself relabels the whole transcript, not just messages sent afterwards — a
+ /// transcript addressing you by two different names would look like two different people.
+ ///
+ public void SetUserName(string name)
+ {
+ if (_userName == name) return;
+ _userName = name;
+ if (Speaker == Speaker.You) Raise(nameof(Header));
+ }
+
+ ///
+ /// Waiting on the model with nothing to show yet — the gap between sending and the first token,
+ /// which on a busy free model can run to tens of seconds. Distinct from
+ /// so the two states can look different: an empty caret blinking at nothing reads as a stall,
+ /// not as progress.
+ ///
+ public bool IsThinking => IsStreaming && !HasText;
+
+ /// Tokens are arriving; the caret trails the text.
+ public bool IsTyping => IsStreaming && HasText;
public ObservableCollection Blocks { get; } = new();
public string Text
{
get => _text;
- private set { if (Set(ref _text, value)) Raise(nameof(HasText)); }
+ private set
+ {
+ if (!Set(ref _text, value)) return;
+ Raise(nameof(HasText));
+ Raise(nameof(IsThinking));
+ Raise(nameof(IsTyping));
+ }
}
public bool HasText => _text.Length > 0;
@@ -52,7 +94,12 @@ public string Text
public bool IsStreaming
{
get => _isStreaming;
- set => Set(ref _isStreaming, value);
+ set
+ {
+ if (!Set(ref _isStreaming, value)) return;
+ Raise(nameof(IsThinking));
+ Raise(nameof(IsTyping));
+ }
}
public string? ModelId
diff --git a/src/OpenKey.Gui/Views/Brandmark.axaml b/src/OpenKey.Gui/Views/Brandmark.axaml
new file mode 100644
index 0000000..19e1122
--- /dev/null
+++ b/src/OpenKey.Gui/Views/Brandmark.axaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
diff --git a/src/OpenKey.Gui/Views/Brandmark.axaml.cs b/src/OpenKey.Gui/Views/Brandmark.axaml.cs
new file mode 100644
index 0000000..a5ad50e
--- /dev/null
+++ b/src/OpenKey.Gui/Views/Brandmark.axaml.cs
@@ -0,0 +1,15 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace OpenKey.Gui.Views;
+
+///
+/// The OpenKey mark. Size it by setting Width and Height at the call site — the
+/// geometry is a 24-unit master scaled by a Viewbox.
+///
+public partial class Brandmark : UserControl
+{
+ public Brandmark() => InitializeComponent();
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+}
diff --git a/src/OpenKey.Gui/Views/CodeBlockView.axaml b/src/OpenKey.Gui/Views/CodeBlockView.axaml
index 30c5451..a37e1b7 100644
--- a/src/OpenKey.Gui/Views/CodeBlockView.axaml
+++ b/src/OpenKey.Gui/Views/CodeBlockView.axaml
@@ -4,8 +4,10 @@
x:Class="OpenKey.Gui.Views.CodeBlockView"
x:DataType="vm:MarkdownBlock">
+
@@ -14,10 +16,11 @@
-
@@ -30,7 +33,7 @@
FontFamily="Cascadia Mono,Consolas,Courier New,monospace"
FontSize="13"
TextWrapping="NoWrap"
- Foreground="{DynamicResource Body}" />
+ Foreground="{DynamicResource CodeText}" />
diff --git a/src/OpenKey.Gui/Views/CodeBlockView.axaml.cs b/src/OpenKey.Gui/Views/CodeBlockView.axaml.cs
index 7f5f705..81b2a50 100644
--- a/src/OpenKey.Gui/Views/CodeBlockView.axaml.cs
+++ b/src/OpenKey.Gui/Views/CodeBlockView.axaml.cs
@@ -48,7 +48,19 @@ private void Render()
text.Text = null;
foreach (var token in tokens)
{
- text.Inlines?.Add(new Run(token.Text) { Foreground = BrushFor(token.Kind) });
+ var run = new Run(token.Text) { Foreground = BrushFor(token.Kind) };
+
+ // mono has no hue to spend, and its top luminance steps sit close enough that
+ // keyword and type would not separate on lightness alone. Weight and slant are the
+ // legitimate substitute; in the coloured themes they would just be noise on top of
+ // a distinction the colour already makes.
+ if (GuiTheme.Current == GuiTheme.Mono)
+ {
+ if (token.Kind == TokenKind.Keyword) run.FontWeight = FontWeight.SemiBold;
+ if (token.Kind == TokenKind.Comment) run.FontStyle = FontStyle.Italic;
+ }
+
+ text.Inlines?.Add(run);
}
}
@@ -65,7 +77,7 @@ private IBrush BrushFor(TokenKind kind)
TokenKind.Comment => "CodeComment",
TokenKind.Number => "CodeNumber",
TokenKind.Type => "CodeType",
- _ => "Body",
+ _ => "CodeText",
};
return this.TryFindResource(key, out var value) && value is IBrush brush
diff --git a/src/OpenKey.Gui/Views/ConfirmWindow.axaml b/src/OpenKey.Gui/Views/ConfirmWindow.axaml
index 78c33c7..7fe8e38 100644
--- a/src/OpenKey.Gui/Views/ConfirmWindow.axaml
+++ b/src/OpenKey.Gui/Views/ConfirmWindow.axaml
@@ -5,22 +5,22 @@
CanResize="False"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
- Background="{StaticResource Surface}"
+ Background="{DynamicResource SurfaceOverlay}"
Title="OpenKey">
-
-
-
-
+
+
+
+
-
-
-
+
+
+
diff --git a/src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs b/src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs
index 3db42f9..75a309a 100644
--- a/src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs
+++ b/src/OpenKey.Gui/Views/ConfirmWindow.axaml.cs
@@ -8,12 +8,20 @@ public partial class ConfirmWindow : Window
{
public ConfirmWindow() => InitializeComponent();
- public ConfirmWindow(string title, string body, string confirmLabel) : this()
+ ///
+ /// Styles the confirm button as destructive. Off by default, because this window is also used
+ /// for plain acknowledgement — About reused it and inherited a dark-red "Close" button, which
+ /// on the light theme was a maroon block on near-white.
+ ///
+ public ConfirmWindow(string title, string body, string confirmLabel, bool destructive = false) : this()
{
Title = title;
this.FindControl("TitleText")!.Text = title;
this.FindControl("BodyText")!.Text = body;
- this.FindControl("ConfirmButton")!.Content = confirmLabel;
+
+ var confirm = this.FindControl("ConfirmButton")!;
+ confirm.Content = confirmLabel;
+ confirm.Classes.Add(destructive ? "destructive" : "primary");
Opened += (_, _) => this.FindControl("CancelButton")?.Focus();
}
diff --git a/src/OpenKey.Gui/Views/InlineText.cs b/src/OpenKey.Gui/Views/InlineText.cs
new file mode 100644
index 0000000..3b0b262
--- /dev/null
+++ b/src/OpenKey.Gui/Views/InlineText.cs
@@ -0,0 +1,100 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Documents;
+using Avalonia.Markup.Xaml.MarkupExtensions;
+using Avalonia.Media;
+using OpenKey.Gui.ViewModels;
+
+namespace OpenKey.Gui.Views;
+
+///
+/// Renders a list of into a 's inlines.
+///
+/// An attached property rather than a control, because the only thing needed is a different way to
+/// fill a text block that the template already has. A wrapping control would bring its own layout,
+/// its own selection behaviour, and a second place for the transcript's typography to drift.
+///
+///
+public static class InlineText
+{
+ /// Monospace stack, matching CodeBlockView so inline and fenced code agree.
+ private static readonly FontFamily Mono = new("Cascadia Mono,Consolas,Courier New,monospace");
+
+ public static readonly AttachedProperty?> SpansProperty =
+ AvaloniaProperty.RegisterAttached?>(
+ "Spans", typeof(InlineText));
+
+ static InlineText() => SpansProperty.Changed.AddClassHandler(OnSpansChanged);
+
+ public static void SetSpans(SelectableTextBlock target, IReadOnlyList? value) =>
+ target.SetValue(SpansProperty, value);
+
+ public static IReadOnlyList? GetSpans(SelectableTextBlock target) =>
+ target.GetValue(SpansProperty);
+
+ private static void OnSpansChanged(SelectableTextBlock target, AvaloniaPropertyChangedEventArgs e)
+ {
+ var spans = e.NewValue as IReadOnlyList;
+
+ target.Inlines?.Clear();
+
+ if (spans is null || spans.Count == 0)
+ {
+ target.Text = string.Empty;
+ return;
+ }
+
+ // One unstyled run is the overwhelmingly common case — most sentences carry no emphasis at
+ // all. Setting Text directly skips building the inline collection for them.
+ if (spans.Count == 1 && spans[0].Style == InlineStyle.None)
+ {
+ target.Text = spans[0].Text;
+ return;
+ }
+
+ // Text and Inlines are alternatives, not additives: a non-null Text would render alongside
+ // the runs and duplicate the paragraph.
+ target.Text = null;
+
+ foreach (var span in spans) target.Inlines?.Add(ToRun(span));
+ }
+
+ private static Run ToRun(InlineSpan span)
+ {
+ var run = new Run(span.Text);
+
+ if (span.IsBold) run.FontWeight = FontWeight.SemiBold;
+ if (span.IsItalic) run.FontStyle = FontStyle.Italic;
+
+ if (span.IsCode)
+ {
+ // Foreground and face only, no background. A highlight behind a run does not follow the
+ // text's line boxes when it wraps, so a long inline snippet breaks into ragged blocks.
+ run.FontFamily = Mono;
+ run.FontSize = 13;
+ Tint(run, "CodeType");
+ }
+
+ if (span.IsLink)
+ {
+ Tint(run, "Brand");
+ run.TextDecorations = TextDecorations.Underline;
+ }
+
+ if (span.IsStruck)
+ {
+ run.TextDecorations = TextDecorations.Strikethrough;
+ Tint(run, "Muted");
+ }
+
+ return run;
+ }
+
+ ///
+ /// Binds the foreground to a theme token rather than resolving it now. A resolved brush would be
+ /// correct at the moment the message rendered and wrong after the next theme switch, since
+ /// nothing rebuilds an already-displayed transcript.
+ ///
+ private static void Tint(Run run, string token) =>
+ run[!TextElement.ForegroundProperty] = new DynamicResourceExtension(token);
+}
diff --git a/src/OpenKey.Gui/Views/MainWindow.axaml b/src/OpenKey.Gui/Views/MainWindow.axaml
index b5a7b39..4ffcf34 100644
--- a/src/OpenKey.Gui/Views/MainWindow.axaml
+++ b/src/OpenKey.Gui/Views/MainWindow.axaml
@@ -4,87 +4,67 @@
xmlns:conv="using:OpenKey.Gui.Converters"
xmlns:views="using:OpenKey.Gui.Views"
xmlns:core="using:OpenKey.Core.Providers"
+ xmlns:storage="using:OpenKey.Core.Storage"
x:Class="OpenKey.Gui.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="OpenKey"
Width="900" Height="680"
- MinWidth="560" MinHeight="420"
+ MinWidth="640" MinHeight="420"
Background="{DynamicResource Surface}">
+
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
+
-
@@ -94,13 +74,13 @@
+
-
+
@@ -112,13 +92,18 @@
-
+
-
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
-
+
+
+
+
-
+
-
-
-
+
+
-
+ IsEnabled="{Binding CanSend}" VerticalAlignment="Bottom"
+ ToolTip.Tip="Send this message (Enter)" />
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/src/OpenKey.Gui/Views/MainWindow.axaml.cs b/src/OpenKey.Gui/Views/MainWindow.axaml.cs
index 069c18a..205c1a5 100644
--- a/src/OpenKey.Gui/Views/MainWindow.axaml.cs
+++ b/src/OpenKey.Gui/Views/MainWindow.axaml.cs
@@ -1,3 +1,4 @@
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
@@ -6,6 +7,7 @@
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using OpenKey.Core.Providers;
+using OpenKey.Core.Storage;
using OpenKey.Gui.ViewModels;
namespace OpenKey.Gui.Views;
@@ -93,17 +95,92 @@ private async void OnSend(object? sender, RoutedEventArgs e)
private void OnStop(object? sender, RoutedEventArgs e) => Vm.Stop();
- private async void OnClearChat(object? sender, RoutedEventArgs e)
+ private async void OnNewChat(object? sender, RoutedEventArgs e)
{
- await Vm.ClearConversationAsync();
+ await Vm.NewChatAsync();
ScrollToBottomSoon();
FocusComposer();
}
- private async void OnUndoClear(object? sender, RoutedEventArgs e)
+ /// Width the chat list returns to when shown again, updated by the splitter.
+ private double _sidebarWidth = 228;
+
+ private void OnToggleChats(object? sender, RoutedEventArgs e)
{
- await Vm.UndoClearAsync();
- ScrollToBottomSoon();
+ Vm.ShowChats = !Vm.ShowChats;
+ ApplySidebarWidth();
+ }
+
+ ///
+ /// Hiding the panel has to zero its column as well. IsVisible collapses the Border but
+ /// leaves the ColumnDefinition at its width, so toggling the chat list off used to leave
+ /// a 228px empty strip where it had been.
+ ///
+ private void ApplySidebarWidth()
+ {
+ if (this.FindControl("ConversationGrid")?.ColumnDefinitions is not { Count: > 0 } columns)
+ return;
+
+ var column = columns[0];
+
+ if (Vm.ShowChats)
+ {
+ column.MinWidth = SidebarMinWidth;
+ column.Width = new GridLength(_sidebarWidth);
+ return;
+ }
+
+ // Remember where the splitter left it before collapsing, so showing it again does not
+ // discard a width the user chose.
+ if (column.Width.IsAbsolute && column.Width.Value > 0) _sidebarWidth = column.Width.Value;
+
+ column.MinWidth = 0;
+ column.Width = new GridLength(0);
+ }
+
+ private const double SidebarMinWidth = 180;
+
+ private async void OnDeleteChat(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem { DataContext: ChatSummary chat }) return;
+
+ // Deleting a conversation cannot be undone — there is nowhere to put it — so unlike
+ // starting a new chat this one asks first.
+ var confirm = new ConfirmWindow(
+ "Delete this chat?",
+ $"\"{chat.Title}\" will be permanently deleted from this PC.",
+ "Delete",
+ destructive: true);
+
+ if (await confirm.ShowDialog(this)) await Vm.DeleteChatAsync(chat);
+ }
+
+ private async void OnRenameChat(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem { DataContext: ChatSummary chat }) return;
+
+ var dialog = new RenameWindow(chat.Title);
+ if (await dialog.ShowDialog(this) is { } title)
+ {
+ if (chat.Id != Vm.SelectedChat?.Id) await Vm.OpenChatAsync(chat.Id);
+ await Vm.RenameCurrentChatAsync(title);
+ }
+ }
+
+ ///
+ /// Changes what OpenKey calls you. Deliberately not asked at first run — that screen already
+ /// asks for a key, and the Windows account name is right almost every time, so this is
+ /// editable rather than demanded.
+ ///
+ private async void OnChangeName(object? sender, RoutedEventArgs e)
+ {
+ var dialog = new RenameWindow(
+ Vm.UserName,
+ heading: "What should OpenKey call you?",
+ placeholder: "Your name",
+ confirmLabel: "Save");
+
+ if (await dialog.ShowDialog(this) is { } name) Vm.SetUserName(name);
}
private void OnThemeMenu(object? sender, RoutedEventArgs e)
@@ -129,19 +206,28 @@ private async void OnRetry(object? sender, RoutedEventArgs e)
await Vm.RetryAsync();
}
- private async void OnCopyLast(object? sender, RoutedEventArgs e)
+ ///
+ /// Copies the reply the button belongs to.
+ ///
+ /// This replaces a single header button that always copied the latest reply: scroll up,
+ /// read an older answer, press Copy, and you silently got a different message than the one you
+ /// were looking at. A per-message action needs to be attached to the message.
+ ///
+ ///
+ private async void OnCopyTurn(object? sender, RoutedEventArgs e)
{
- if (Vm.LastReply is not { } text)
- {
- Vm.NotifyStatus(StatusKind.Info, "No reply to copy yet.");
- return;
- }
+ if (sender is not Button { DataContext: MessageViewModel turn } button || !turn.HasText) return;
- var clipboard = GetTopLevel(this)?.Clipboard;
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
- await clipboard.SetTextAsync(text);
- Vm.NotifyStatus(StatusKind.Ok, "Last reply copied to the clipboard.");
+ await clipboard.SetTextAsync(turn.Text);
+
+ // Confirm on the button itself, like the code block does. No status message: you are
+ // already looking at the thing you pressed.
+ button.Content = "Copied";
+ await Task.Delay(1400);
+ button.Content = "Copy";
}
private async void OnExport(object? sender, RoutedEventArgs e)
@@ -191,7 +277,8 @@ private async void OnResetClicked(object? sender, RoutedEventArgs e)
"Erase everything?",
"Your saved key and your entire chat history will be deleted from this PC. "
+ "You'll need to sign in again.",
- "Erase everything");
+ "Erase everything",
+ destructive: true);
if (await confirm.ShowDialog(this)) await Vm.ResetEverythingAsync();
}
diff --git a/src/OpenKey.Gui/Views/RenameWindow.axaml b/src/OpenKey.Gui/Views/RenameWindow.axaml
new file mode 100644
index 0000000..83a3ba5
--- /dev/null
+++ b/src/OpenKey.Gui/Views/RenameWindow.axaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OpenKey.Gui/Views/RenameWindow.axaml.cs b/src/OpenKey.Gui/Views/RenameWindow.axaml.cs
new file mode 100644
index 0000000..3e0a482
--- /dev/null
+++ b/src/OpenKey.Gui/Views/RenameWindow.axaml.cs
@@ -0,0 +1,40 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace OpenKey.Gui.Views;
+
+public partial class RenameWindow : Window
+{
+ public RenameWindow() => InitializeComponent();
+
+ ///
+ /// What is being renamed. Parameterised because the name dialog is the same shape as the chat
+ /// one, and a second window differing only in a string would be two things to keep in sync.
+ ///
+ public RenameWindow(
+ string current,
+ string heading = "Rename this chat",
+ string placeholder = "Chat name",
+ string confirmLabel = "Rename")
+ : this()
+ {
+ Title = heading;
+ this.FindControl("HeadingText")!.Text = heading;
+ this.FindControl("ConfirmButton")!.Content = confirmLabel;
+
+ var box = this.FindControl("TitleBox")!;
+ box.PlaceholderText = placeholder;
+ box.Text = current;
+
+ // Selected, so typing replaces the old name — renaming usually means replacing.
+ Opened += (_, _) => { box.SelectAll(); box.Focus(); };
+ }
+
+ private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
+
+ private void OnRename(object? sender, RoutedEventArgs e) =>
+ Close(this.FindControl("TitleBox")?.Text?.Trim() is { Length: > 0 } t ? t : null);
+
+ private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
+}
diff --git a/src/OpenKey.Windows/AppPaths.cs b/src/OpenKey.Windows/AppPaths.cs
index 1c8dffd..48dc29e 100644
--- a/src/OpenKey.Windows/AppPaths.cs
+++ b/src/OpenKey.Windows/AppPaths.cs
@@ -2,9 +2,33 @@
namespace OpenKey.Windows;
+///
+/// Where OpenKey keeps its files: %APPDATA%\OpenKey\ by default.
+///
+/// Set OPENKEY_HOME to point it somewhere else. That exists so the app can be exercised
+/// against a scratch directory instead of real conversations — there was previously no way to run
+/// it without touching the only copy of someone's chat history, which is a bad property for a tool
+/// whose own tests involve deleting chats.
+///
+///
+/// The key stays DPAPI-encrypted for the current Windows account wherever the folder lives, so a
+/// redirected home is not a way to make the key portable. See docs/05-persistence-and-reset.md.
+///
+///
public sealed class WindowsAppPaths : IAppPaths
{
- public string RootDir { get; } = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
- "OpenKey");
+ public const string HomeVariable = "OPENKEY_HOME";
+
+ public string RootDir { get; } = Resolve();
+
+ internal static string Resolve(string? overrideValue = null)
+ {
+ var home = overrideValue ?? Environment.GetEnvironmentVariable(HomeVariable);
+
+ // Whitespace is treated as unset rather than as a path: an empty variable left over from a
+ // shell script should fall back to the real location, not resolve to the current directory.
+ return string.IsNullOrWhiteSpace(home)
+ ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenKey")
+ : Path.GetFullPath(home.Trim());
+ }
}
diff --git a/src/OpenKey/CommandRouter.cs b/src/OpenKey/CommandRouter.cs
index 524dd64..d27d01f 100644
--- a/src/OpenKey/CommandRouter.cs
+++ b/src/OpenKey/CommandRouter.cs
@@ -5,6 +5,7 @@
using OpenKey.Core.Engine;
using OpenKey.Core.Providers;
using OpenKey.Core.Storage;
+using OpenKey.Core.Text;
using OpenKey.Ui;
using Spectre.Console;
@@ -42,11 +43,16 @@ public CommandRouter(
public async Task HandleAsync(string input, CancellationToken ct)
{
+ // Normalize before the test, not after. This ran on the raw string, so a leading space or
+ // an invisible character left by a paste meant "/help" was not recognised as a command and
+ // went to the model as a message instead.
+ input = UserInput.Normalize(input);
+
if (!input.StartsWith('/')) return CommandResult.NotACommand;
PendingResend = null;
- var parts = input.Trim().Split(' ', 2);
+ var parts = input.Split(' ', 2);
var cmd = parts[0].ToLowerInvariant();
var arg = parts.Length > 1 ? parts[1].Trim() : null;
@@ -64,6 +70,22 @@ public async Task HandleAsync(string input, CancellationToken ct)
await StartNewConversationAsync(ct);
return CommandResult.Handled;
+ case "/chats":
+ await ShowChatsAsync(ct);
+ return CommandResult.Handled;
+
+ case "/chat":
+ await SwitchChatAsync(arg, ct);
+ return CommandResult.Handled;
+
+ case "/rename":
+ await RenameChatAsync(arg, ct);
+ return CommandResult.Handled;
+
+ case "/delete":
+ await DeleteChatAsync(arg, ct);
+ return CommandResult.Handled;
+
case "/retry":
return Retry();
@@ -83,6 +105,10 @@ public async Task HandleAsync(string input, CancellationToken ct)
SetTheme(arg);
return CommandResult.Handled;
+ case "/name":
+ SetUserName(arg);
+ return CommandResult.Handled;
+
case "/model":
ShowActiveModel();
return CommandResult.Handled;
@@ -127,20 +153,145 @@ public async Task HandleAsync(string input, CancellationToken ct)
}
///
- /// Clears the conversation but keeps the key. Previously the only way to start fresh was
- /// /reset, which also deleted the key and forced a new sign-in.
+ /// Starts a conversation alongside the existing ones. Nothing is destroyed — the previous chat
+ /// stays in /chats.
///
private async Task StartNewConversationAsync(CancellationToken ct)
{
if (!_engine.Turns.Any(t => t.Role != ChatMessage.SystemRole))
{
- Components.HintLine("Already a fresh conversation.");
+ Components.HintLine("This chat is already empty.");
return;
}
await _engine.NewSessionAsync(ct);
_clearScreen();
- Components.SuccessLine("Started a new conversation. Your key is untouched.");
+ Components.SuccessLine("Started a new chat. The previous one is in /chats.");
+ }
+
+ private async Task> ShowChatsAsync(CancellationToken ct)
+ {
+ var chats = await _engine.ListChatsAsync(ct);
+ if (chats.Count == 0)
+ {
+ Components.HintLine("No saved chats yet.");
+ return chats;
+ }
+
+ var table = new Table()
+ .Border(Glyphs.Table)
+ .BorderColor(Color.Grey)
+ .Expand()
+ .AddColumn(new TableColumn($"[{Theme.Strong}]#[/]").Width(4))
+ .AddColumn(new TableColumn($"[{Theme.Strong}]Chat[/]"))
+ .AddColumn(new TableColumn($"[{Theme.Strong}]Messages[/]").Width(10))
+ .AddColumn(new TableColumn($"[{Theme.Strong}]Last used[/]").Width(16));
+
+ for (var i = 0; i < chats.Count; i++)
+ {
+ var c = chats[i];
+ var current = c.Id == _engine.CurrentChatId;
+ var style = current ? Theme.Brand : Theme.Strong;
+ table.AddRow(
+ $"[{Theme.Muted}]{i + 1}[/]",
+ $"[{style}]{Markup.Escape(c.Title)}[/]" + (current ? $" [{Theme.Muted}](open)[/]" : string.Empty),
+ $"[{Theme.Muted}]{c.MessageCount}[/]",
+ $"[{Theme.Muted}]{Markup.Escape(Ago(c.UpdatedAt))}[/]");
+ }
+
+ AnsiConsole.Write(table);
+ AnsiConsole.WriteLine();
+ Components.HintLine("Type /chat 2 to switch, /rename for this one, /delete 2 to remove one.");
+ return chats;
+ }
+
+ private async Task SwitchChatAsync(string? arg, CancellationToken ct)
+ {
+ var chats = await ShowChatsAsync(ct);
+ if (chats.Count == 0) return;
+ if (string.IsNullOrWhiteSpace(arg)) return;
+
+ if (!TryResolve(arg, chats, out var chat))
+ {
+ Components.HintLine($"No chat {Markup.Escape(arg)}. Use a number from /chats.");
+ return;
+ }
+
+ if (await _engine.OpenChatAsync(chat.Id, ct))
+ {
+ _clearScreen();
+ Components.SuccessLine($"Opened \"{chat.Title}\".");
+ }
+ else
+ {
+ Components.HintLine("That chat could not be opened.");
+ }
+ }
+
+ private async Task RenameChatAsync(string? arg, CancellationToken ct)
+ {
+ if (_engine.CurrentChatId is not { } id)
+ {
+ Components.HintLine("Nothing to rename yet — send a message first.");
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(arg))
+ {
+ Components.HintLine("Give it a name, for example /rename Trip planning");
+ return;
+ }
+
+ await _engine.RenameChatAsync(id, arg, ct);
+ Components.SuccessLine($"Renamed to \"{arg.Trim()}\".");
+ }
+
+ private async Task DeleteChatAsync(string? arg, CancellationToken ct)
+ {
+ var chats = await _engine.ListChatsAsync(ct);
+ if (chats.Count == 0)
+ {
+ Components.HintLine("No saved chats yet.");
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(arg) || !TryResolve(arg, chats, out var chat))
+ {
+ Components.HintLine("Say which one, for example /delete 2. Use /chats to see the numbers.");
+ return;
+ }
+
+ if (!Prompts.Confirm($"Delete \"{chat.Title}\"? This can't be undone."))
+ {
+ Components.HintLine("Nothing was changed.");
+ return;
+ }
+
+ await _engine.DeleteChatAsync(chat.Id, ct);
+ Components.SuccessLine($"Deleted \"{chat.Title}\".");
+ }
+
+ private static bool TryResolve(string arg, IReadOnlyList chats, out ChatSummary chat)
+ {
+ if (int.TryParse(arg.Trim(), out var n) && n >= 1 && n <= chats.Count)
+ {
+ chat = chats[n - 1];
+ return true;
+ }
+
+ chat = default!;
+ return false;
+ }
+
+ /// Relative time reads faster than a timestamp when scanning a list.
+ private static string Ago(DateTimeOffset when)
+ {
+ var d = DateTimeOffset.UtcNow - when;
+ if (d < TimeSpan.FromMinutes(1)) return "just now";
+ if (d < TimeSpan.FromHours(1)) return $"{(int)d.TotalMinutes}m ago";
+ if (d < TimeSpan.FromDays(1)) return $"{(int)d.TotalHours}h ago";
+ if (d < TimeSpan.FromDays(30)) return $"{(int)d.TotalDays}d ago";
+ return when.LocalDateTime.ToString("d MMM yyyy", System.Globalization.CultureInfo.CurrentCulture);
}
private CommandResult Retry()
@@ -174,7 +325,7 @@ private void ShowHistory()
foreach (var t in turns)
{
- var who = t.Role == ChatMessage.UserRole ? Environment.UserName : "OpenKey AI";
+ var who = t.Role == ChatMessage.UserRole ? _config.Current.DisplayName : "OpenKey AI";
var style = t.Role == ChatMessage.UserRole ? Theme.Strong : Theme.Brand;
table.AddRow(
$"[{style}]{Markup.Escape(who)}[/]",
@@ -276,6 +427,42 @@ private void CopyLastReply()
}
}
+ ///
+ /// Sets the label shown on your own messages. Blank shows the current value; "reset" goes back
+ /// to the Windows account name.
+ ///
+ /// There is no prompt for this at first run. The first run already asks for a key, and a second
+ /// question before anything useful has happened is a tax — especially when the account name is
+ /// right almost every time. Editable and discoverable beats asked-up-front.
+ ///
+ ///
+ private void SetUserName(string? name)
+ {
+ var current = _config.Current;
+
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ AnsiConsole.MarkupLine(
+ $"OpenKey calls you [{Theme.Brand}]{Markup.Escape(current.DisplayName)}[/]"
+ + (current.UserName is null ? $" [{Theme.Muted}](your Windows account name)[/]" : string.Empty));
+ Components.HintLine("Change it with /name Sam, or /name reset to go back to your Windows account name.");
+ return;
+ }
+
+ var wanted = name.Trim();
+
+ if (string.Equals(wanted, "reset", StringComparison.OrdinalIgnoreCase))
+ {
+ _config.Save(current.WithUserName(null));
+ Components.SuccessLine($"Back to {_config.Current.DisplayName}.");
+ return;
+ }
+
+ // Trimming and the length cap belong to WithUserName, so every entry point agrees.
+ _config.Save(current.WithUserName(wanted));
+ Components.SuccessLine($"OpenKey will call you {_config.Current.DisplayName}.");
+ }
+
private void SetTheme(string? name)
{
if (string.IsNullOrWhiteSpace(name))
@@ -385,6 +572,7 @@ private void ShowAbout() =>
Components.KeyValuePanel("About OpenKey", new (string, string)[]
{
("Version", Components.Version),
+ ("This chat", _engine.CurrentChatId is null ? "Not saved yet — send a message" : _engine.CurrentChatTitle),
("Answering with", _engine.ActiveModel?.Id ?? "Nothing yet — send a message"),
("Model choice", _engine.PreferredModelId ?? "Automatic"),
("Your data", _paths.RootDir),
@@ -405,7 +593,11 @@ void Row(string cmd, string what) =>
table.AddRow($"[{Theme.Brand}]{cmd}[/]", what);
table.AddRow($"[{Theme.Muted}]Chatting[/]", string.Empty);
- Row("/new", "Start a fresh conversation, keeping your key");
+ Row("/new", "Start another chat, keeping this one");
+ Row("/chats", "List your saved chats");
+ Row("/chat", "Switch to another chat, e.g. /chat 2");
+ Row("/rename", "Rename this chat");
+ Row("/delete", "Delete a chat, e.g. /delete 2");
Row("/retry", "Send your last message again");
Row("/history", "Show the conversation so far");
Row("/copy", "Copy the last reply to the clipboard");
@@ -419,6 +611,7 @@ void Row(string cmd, string what) =>
table.AddEmptyRow();
table.AddRow($"[{Theme.Muted}]OpenKey[/]", string.Empty);
Row("/theme", "Switch colours: default, dark, light, mono");
+ Row("/name", "Change what OpenKey calls you");
Row("/about", "Show version, where your data lives, and who made this");
Row("/cls", "Clear the screen");
Row("/help", "Show this list");
diff --git a/src/OpenKey/ConsoleHost.cs b/src/OpenKey/ConsoleHost.cs
index b6b64bf..723557f 100644
--- a/src/OpenKey/ConsoleHost.cs
+++ b/src/OpenKey/ConsoleHost.cs
@@ -4,6 +4,7 @@
using OpenKey.Core.Engine;
using OpenKey.Core.Providers;
using OpenKey.Core.Storage;
+using OpenKey.Core.Text;
using OpenKey.Core.Updates;
using OpenKey.Windows;
using OpenKey.Windows.OAuth;
@@ -16,13 +17,14 @@ namespace OpenKey;
public sealed class ConsoleHost
{
// Computed, not a field initializer: those run at DI construction, before ConsoleLayout.Initialize
- // resolves the glyph tier, so a cached value would always be the ASCII fallback.
- private static string UserPrompt =>
- $"[{Theme.Strong}]{Markup.Escape(Environment.UserName)}[/] [{Theme.Brand}]{Glyphs.Caret}[/] ";
+ // resolves the glyph tier, so a cached value would always be the ASCII fallback. It also has to
+ // re-read the name, which /name can change mid-session.
+ private string UserPrompt =>
+ $"[{Theme.Strong}]{Markup.Escape(_config.Current.DisplayName)}[/] [{Theme.Brand}]{Glyphs.Caret}[/] ";
private readonly IAppPaths _paths;
private readonly IKeyStore _keyStore;
- private readonly ISessionStore _sessions;
+ private readonly IChatStore _chats;
private readonly IModelCatalog _catalog;
private readonly IRotationPolicy _rotation;
private readonly ChatEngine _engine;
@@ -41,7 +43,7 @@ public sealed class ConsoleHost
public ConsoleHost(
IAppPaths paths,
IKeyStore keyStore,
- ISessionStore sessions,
+ IChatStore chats,
IModelCatalog catalog,
IRotationPolicy rotation,
ChatEngine engine,
@@ -51,7 +53,7 @@ public ConsoleHost(
{
_paths = paths;
_keyStore = keyStore;
- _sessions = sessions;
+ _chats = chats;
_catalog = catalog;
_rotation = rotation;
_engine = engine;
@@ -104,6 +106,10 @@ public async Task RunAsync()
while (!_exiting)
{
+ // Before the prompt, not after: a notice that appears once you have already typed
+ // reads as a response to what you typed.
+ ShowUpdateNoticeIfAny();
+
string? line;
try
{
@@ -116,9 +122,11 @@ public async Task RunAsync()
if (line is null) break; // EOF / Ctrl+D
if (_exiting) break;
- if (string.IsNullOrWhiteSpace(line)) continue;
- ShowUpdateNoticeIfAny();
+ // Once, at the boundary, so the command router and the message that reaches the model
+ // see the same clean text.
+ line = UserInput.Normalize(line);
+ if (line.Length == 0) continue;
var result = await _commands.HandleAsync(line, CancellationToken.None);
if (result == CommandResult.Exit) break;
@@ -291,7 +299,14 @@ private static void ShowChatError(ChatException ex)
Components.StatusCard(severity, title, detail, next);
}
- private static void ClearAndShowChatHeader() => Components.HomeHeader();
+ private void ClearAndShowChatHeader()
+ {
+ Components.HomeHeader();
+
+ // Only worth naming once there is more than one conversation to confuse it with.
+ var count = _engine.Turns.Count(t => t.Role != ChatMessage.SystemRole);
+ if (count > 0) Components.ChatHeading(_engine.CurrentChatTitle, count);
+ }
private UpdateInfo? _pendingUpdate;
@@ -332,7 +347,7 @@ private void ShowResumeRecapIfAny()
foreach (var t in nonSystem.TakeLast(2))
{
- var label = t.Role == ChatMessage.UserRole ? Environment.UserName : "OpenKey AI";
+ var label = t.Role == ChatMessage.UserRole ? _config.Current.DisplayName : "OpenKey AI";
var flat = t.Content.ReplaceLineEndings(" ").Trim();
var preview = flat.Length > 70 ? flat[..70] + Glyphs.Ellipsis : flat;
AnsiConsole.MarkupLine(
@@ -627,7 +642,7 @@ private async Task ResetAllAsync(CancellationToken ct)
/// is redirected, which Spectre prompts now do; and a multi-line paste leaves its remaining
/// lines in the driver buffer where they can be drained instead of being executed as commands.
///
- private static string? ReadUserLine()
+ private string? ReadUserLine()
{
AnsiConsole.WriteLine();
AnsiConsole.Markup(UserPrompt);
diff --git a/src/OpenKey/OpenKey.csproj b/src/OpenKey/OpenKey.csproj
index 635ea6d..4bf6745 100644
--- a/src/OpenKey/OpenKey.csproj
+++ b/src/OpenKey/OpenKey.csproj
@@ -6,7 +6,7 @@
OpenKeywin-x64;win-arm64false
-
+ ../../assets/openkey.ico