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 @@ true latest Recommended - 0.3.0 + 0.4.0 en-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-arm64 false app.manifest + + + ../../assets/openkey.ico true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ - @@ -112,13 +92,18 @@ - + - + + + - - - 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 @@ OpenKey win-x64;win-arm64 false - + ../../assets/openkey.ico