Windows: enhanced opencode integration (sessions, persistent server), island revamp (drag, compact, hide), small corrections and bug fixes - #47
Conversation
…te package version
Builds on Louis-CFM#31. The compact-resting bar left a 240px wake strip behind from when the island was always centred in the window; once the bar became draggable that strip became a hit area to its left, which is why an accidental wake could only ever happen on that side. Removed it and made the island its own wake target. Waking is now deliberate: a double-click within 56px of the bar, measured from its painted rect on both sides so left and right are identical by construction. A single outside click while reduced does nothing, otherwise every stray click on the desktop would toggle it. Two bugs fixed while making that behaviour explicit: the extra click to fully compact called collapse() while the island was already on the bar, which the state machine ignores as a transition to its current state and so did nothing at all; and the fully-compact timer was gated on the cursor being off the island, so a click inside the 14px margin left the bar resting forever. The configured delay now always owns that last step.
Completes the opencode side of Louis-CFM#19. The command and session lists only exist on a long-lived HTTP server, and with no TUI running there was none, so the slash menu opened empty. Coucou now finds one: a server already running is reused, otherwise it starts its own on a fixed port. The working directory turned out to decide everything. opencode serve scopes both the session list and the project commands to it, so inheriting Coucou's own directory answered with an empty session list and a fraction of the installed plugins; started in the user's home it matches the TUI's server exactly. Passing ?directory= on the request does not change this. Also: the per-probe timeout was short enough that a server still loading its config read as absent; two lookups racing on a cold start could both try to bind the port, so startup is now serialised; the real executable is preferred over a scoop shim, which starts the genuine binary as a second process and made the child handle useless for cleanup; and the port is claimed without a console window appearing.
…er message Every message ran opencode run, which boots a throwaway server, does one turn and tears it down again. That left the command and session lists reading a different, long-lived server than the one answering the chat, so one worked while the other did not. A turn is now a pair of HTTP calls against the server that is already running. opencode run is kept where it is the right tool: a slash command owns its whole turn, and an attachment needs --file, which the server route would mean reimplementing as an upload. A failed server turn logs and falls back rather than losing the message. With the provider set to Claude and no key stored, the turn goes to opencode rather than stopping on " API key missing\.
The slash menu appeared to ignore what was typed and always showed every command. closePopups() detached the menu but never emptied it, and the element is built once and reused, so the rows from the first render stayed as children and each narrowed render appended underneath them. The filter itself was correct throughout; it was writing into an element that still held the previous list. Filtering is now also synchronous rather than queued behind the command fetch, so a keystroke never waits on a request that a previous keystroke started. Typing a fragment that matches no prefix falls back to a substring match. The picker listed the oldest session first, because the server already returns newest-first and the list was reversed on top of it; it now sorts by creation time so it cannot depend on that holding. Sessions can be deleted from the row, behind a two-step confirm because a stray click on a small row would destroy a conversation. Picking one loads its history into the chat window, and /sessions opens the picker instead of replying with text telling you to pick one.
…ection On, one server runs from launch until the app quits and chat goes to it. Off, no server is kept: one is started on demand for the command and session lists and tidied away once nothing has used it for a while, so the lists work without leaving a process behind. Switching it takes effect immediately rather than at the next launch, and turning it on mid-conversation keeps the server already answering instead of replacing it out from under the chat.
Watched upstream issues/PRs and pulled what makes this fork a step ahead: - Louis-CFM#43 adopted: ticker rows overlap on WebKitGTK — the will-change compositing hint is gone, step text is a block so it ellipsizes, and a half-finished step keeps the frame loop alive. Verified to pass our TS build. - Louis-CFM#56 adopted: every view animation pauses while the island is folded or hidden (#content.away) — 0 % CPU when idle, matching the macOS rule. - Louis-CFM#47 vocabulary folded into the OpenCode adapter: session.created/idle/ error/deleted, tool.execute.before/after and message.updated are mapped; permission.asked shows as an observe-only notification (our adapter has no write path by design, unlike upstream's plugin approach). - npm → bun for the front end: bun.lock replaces package-lock.json, tauri conf and docs updated. - Linux CI (bun + cargo tests, .deb/AppImage on linux-v* tags) — upstream's Linux PRs don't have it; ours runs on every push. - docs: LINUX.md gains the "ahead of upstream" playbook; CLAUDE.md the upstream-watch rule. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
Ported from Louis-CFM#47 (05e9fc5, by Imanol Diaz). The compact bar becomes its own wake target: a double-click within 56px opens it, a single outside click does nothing, and the configured delay always owns the step down to the fully-reduced stub. The bar can be dragged along the top edge and pinned open; both are stored in settings. The settings, state, bridge and command plumbing that 05e9fc5 assumes comes from its neighbours in Louis-CFM#47 and is pulled in here on its own, so the feature does not drag the Windows-only opencode server along with it.
The island now has four widths rather than two, and each step is driven by
its own setting:
expanded 640 -> 240 Auto-close (autoCloseInterval)
-> 80 Compact (absenceInterval)
-> off Auto-hide (autoCloseDelay)
A fourth FSM state, gone, replaces the offscreen flag that used to be
inferred from a delay being non-zero. Auto-hide applies from either resting
bar and is armed by reaching one, not by the cursor leaving it: arming it on
mouse-leave meant a cursor parked on the bar suppressed off-screen hiding
indefinitely.
The three gestures are kept distinct. Clicking outside compacts to the 240
bar, and clicking again beside it goes to 80. A double-click near the bar
expands it to the full 240 width from either resting width, and unlike the
widening that wake-on-hover performs it is a real state change, so the bar
stays at 240 when the cursor leaves and the Compact timer owns the next
shrink. Hover-to-restore is the only gesture that recalls the island from
off-screen, and is therefore tied to Auto-hide.
The agent pills follow the bar width rather than the mode: they sit in the
240 bar with Mochi on the left and pills on the right end, as the draft had
them, and never in the expanded panel. Leaving the grid visible there painted
mini Mochis over the settings view and every other full-screen view.
Auto-hide fades the whole island rather than animating its height to zero.
The Mochi already faded itself through botPosition's opacity, but the bar's
own background outlived the zero-height animation and stayed on screen.
Also adds a secondary-display option to the monitor picker, and hardens the
reduced-state cursor hit test in the poll so a collapse cannot race the last
tick and leave the island click-through (Louis-CFM#28).
…the pointer `target_monitor` resolves the display under the pointer exactly once. Every placement after that returns the monitor the island is already on, so moving the mouse to another screen leaves the island where it was and "display under the cursor" reads as broken. The stickiness is deliberate and predates the secondary-display option: a bar being dragged toward an edge takes the pointer with it, so re-resolving from the cursor on every placement would hop the island onto the neighbouring monitor mid-drag and fight the drag. It is the reason the pointer is consulted in the narrow window before the island has a home, and the reason the preference can only take effect on a preference change — `forget_cursor_placement` clears both flags in that case. This is not a regression from the secondary option. The early return sat ahead of the cursor branch in the original ordering too, so the pointer has only ever been read before first placement. What the secondary option changed is that `primary` and `secondary` are now resolved from the setting on every placement instead of sharing that early return, which is why those two work and `cursor` does not. Logging is added here so the behaviour is visible rather than inferred: one line when the pointer places the island, one when it declines to follow. The trace is gated behind flags because the collapse loop places the island many times a second, which made the first version of it unreadable. Not fixed in this commit — the fix needs a decision about whether the island should track the pointer between placements or only when it settles, and that changes drag behaviour.
The "display under the cursor" preference only ever resolved the pointer's display before the island had a home, so moving the mouse to another screen left the island where it was and the preference read as broken. All three display preferences are now resolved from the setting on every placement. The early return that pinned a placed island to whatever display it landed on was there to stop a drag hopping displays — a bar dragged toward an edge takes the pointer with it, so re-resolving mid-drag would move the island to the neighbouring screen and fight the drag. That concern is real but does not justify disabling the preference everywhere else, so it is now scoped to the drag itself: `set_dragging` marks the start and end of one, and only then is the current display held. The front end reports that, since it is what starts the drag, on the first move past the existing 4px threshold. The release clears it before the final placement, so letting go of a bar over a different display moves it there rather than pinning it back. `CURSOR_PLACED` and `forget_cursor_placement` go with it: the flags only existed to model "read the pointer once", which is no longer the behaviour, and removing them also removes the reason a preference change had to reset anything. Verified against two displays: with the pointer at -1713,906 the island places on DISPLAY1, the secondary.
The island can rest anywhere along the top of the screen: drag the bar, or drag the open island by its top band (the header, away from the tabs and buttons), and it stays where it is let go, across restarts. A press that doesn't move is still a click. Settings → General → Island position offers Left / Centre / Right. The position is a fraction, 0..1. Rust slides the window by position × (screen − window) and the island slides inside it by position × (window − island), so it lands at position × (screen − island) whatever its size: never off the display, and at an edge it opens inward. The hidden island's wake strip follows it, and the display the island is on stays put while it is dragged. Taken from upstream Louis-CFM#47's drag (Imanol Diaz), without the rest of that commit, and with three differences: the position is kept at launch; the drag works in logical pixels (screenX and window.screen already are); moves don't write the settings file until release, which also tells the settings window so its copy can't put the old value back. The open island's header also gets a Reduce button (–) that folds it back into its bar right away, like Escape, and hides while an alert is waiting for an answer. Co-Authored-By: Imanol Diaz <imadiaz01@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three defects, all in the path between a click and `dismissOutside`, and all of them made the gestures appear to depend on having dragged the bar first. The click was landing on the island window but not on the island, and nothing was listening for it. That handler was registered only outside Tauri, so the packaged app had a single route to `dismissOutside` — the native poll — and that route is conditional: the window only takes the mouse over the island shape, and the click-through state is re-evaluated as the cursor moves. It is now registered unconditionally, so the webview handles clicks inside the window and the poll handles clicks outside it. That gives one physical click two deliveries, which is why the second one read as the second half of a double-click and woke the very bar it was meant to compact. A short debounce keeps one click from counting twice, which is also what lets the near double-click on the 80pt bar see two real clicks. The press was then classified near or far natively, from the position and the same rect the click-through test uses, instead of by the front end rebuilding that rect. It now travels as a map rather than a tuple: Tauri serialises a tuple as a JSON array, so destructuring it as an object delivered `near` as `undefined` on every native click and the native classification did nothing. And the compact bar dropped the wake flag when it actually compacted. `barHover` forces the fully-compact bar to render at the 240pt width, so a click that compacted it while the cursor was resting on it reached the right state and kept the wide width — the state changed, the pixels did not. That is what made it look like only a drag worked; moving the mouse off the bar cleared the flag, and only then did the width follow. The near/far decision is back to the original shape: near is consulted only for the 80pt bar, where a double-click wakes it to 240. At 240 any click compacts. Hovering no longer cancels the compact timer, which it did when wake-on-hover was on: the cursor is already over the bar when the panel collapses, and the timer is only re-armed on leaving, so it was cancelled and never re-armed.
Resolves 37 upstream commits' worth of drift across 6 conflicting files, keeping this branch's behaviour where the two disagree. Upstream refactored the Win32 calls behind a `platform` module for the Linux port. Adopted here, with two exceptions that only Windows can answer: the native select-menu check stays on the raw Win32 calls, since "is this window one of our own popups" is not a portable question. `outside-click` is untouched by upstream -- PR Louis-CFM#31 is not in main -- so the poll still emits it, and still classifies the press as near or far from the same rect the click-through test uses. The collapsed-state click-through hardening from Louis-CFM#28 is kept on top of upstream's `refresh_click_through`, and the poll stays parked only on platforms without a global cursor position, which is what lets the reduced bar be woken again. `apply_geometry` keeps its `position` argument and the launch stays centred: upstream dropped both, but the resting place is a feature of this branch and is applied once the bar is dragged. Hook routing takes upstream's `agentId`, which added dynamic `agent_<name>` pills for third-party agents, with opencode folded in ahead of the Claude fallback so it keeps its own integration pill. The opencode executable lookup moves to `platform::find_on_path`, which is where upstream moved it. The island's click and wake behaviour is otherwise unchanged: the unconditional pointerdown listener, the double-delivery debounce, dropping the wake flag when the bar actually compacts, and the four-width retraction all survive the merge.
137e921 to
05d92ad
Compare
The island can rest anywhere along the top of the screen: drag the bar, or drag the open island by its top band (the header, away from the tabs and buttons), and it stays where it is let go, across restarts. A press that doesn't move is still a click. Settings → General → Island position offers Left / Centre / Right. The position is a fraction, 0..1. Rust slides the window by position × (screen − window) and the island slides inside it by position × (window − island), so it lands at position × (screen − island) whatever its size: never off the display, and at an edge it opens inward. The hidden island's wake strip follows it, and the display the island is on stays put while it is dragged. Taken from upstream Louis-CFM#47's drag (Imanol Diaz), without the rest of that commit, and with three differences: the position is kept at launch; the drag works in logical pixels (screenX and window.screen already are); moves don't write the settings file until release, which also tells the settings window so its copy can't put the old value back. The open island's header also gets a Reduce button (–) that folds it back into its bar right away, like Escape, and hides while an alert is waiting for an answer. Co-Authored-By: Imanol Diaz <imadiaz01@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The slash-command menu worked but everything it opened did not. Three groups of
fixes, all in the chat window.
The command menu itself:
- The picker handlers were nested inside `if (menuRows.length > 0)`, so they could
never run. A picker opened by a command is never open at the same time as the
command menu, which is why the arrows did nothing in `/sessions` or `/models`
while the menu itself was navigable.
- Enter always completed the highlighted command instead of running what was typed,
because the menu is open from the first character and `menuActive` is never -1.
Every command took two presses. Enter now submits when the text already spells
the command and only fills it in when it does not.
- The rows were `<button>`s, so clicking one took focus and the input stopped
receiving arrow keys. They are divs now: click still works, nothing steals focus.
- The session picker asked again, in an unbounded loop, whenever the list came back
empty. It retried once and then said so. That loop was backing every later request
up behind it, which is why commands stopped loading at all.
- Clicking anywhere else in the chat dismisses a dropdown, and Escape closes it
before doing anything else.
`/models` opened a wall of text with nothing selectable. It now opens a picker, and
the model picked is sent with the turn rather than just displayed. The list is
grouped by provider with each provider's configured default first, and nothing is
truncated — the old 40-item cap was filled entirely by one provider, so the free
opencode models never appeared.
Reading `/config/providers` was the other half of that. `providers` is an array of
`{ id, models: { <id>: {...} } }`, and the code read both levels the wrong way round,
so it returned nothing against a server with 120 models to offer.
Escape cancels a turn in flight. The first press arms it and says so, the second
gives up waiting for the reply.
That cancellation is local, and worth being precise about. The request is a blocking
HTTP call that cannot be recalled, so `chat_send` races it against a cancel flag and
returns immediately — the island stops waiting and the reply is discarded, but the
server keeps generating it. A true abort needs the server told, and its route is not
anything in this repository; the front end now holds no state that assumes otherwise.
The picker was also being squeezed to about one visible row: it is a flex child of a
column, so it was shrunk to fit and its `max-height` never applied.
|
Heads-up from a Linux build check (
The existing |
@arreina Thanks — accurate report, and Context: this branch doesn't author any Linux changes. The merge from current main brings upstream's Linux work in ( Both The adaptation would be ~30 lines across four files, touching no Linux file: the three I'm unsure whether it belongs in this PR — it's already large and deliberately Windows-scoped, so how the @Louis-CFM happy to do it in this PR or leave it to you at merge, whichever you prefer. |
…gical pixels Extends the drag added in 7987bb2 to the expanded island. The header band is the grab area, measured from the layout rather than hardcoded, and a press that lands on a control keeps its own behaviour. A press that never becomes a drag is left to whatever it would otherwise have done, so clicking the header still means what it meant before. The position plumbing already existed: islandOffsetX and islandRect both account for it, so the click-through rect and the native near/far classification are untouched. No new state and no new settings. Also fixes the drag arithmetic. It divided the pointer travel by the display scale factor, but both sides were already logical: screenX is CSS pixels, and Rust divides the monitor metrics by the scale factor before reporting them, so screen.width is logical too. The drag was scaled twice, which is invisible at 100% and under-drags by the scale factor on any scaled display.
The island rewrites its width on every frame while it retracts, and that re-wraps the text of every bubble inside it, so the cost of the animation was proportional to the size of the whole conversation. With a few hundred messages open, the retraction and the collapse to the 80pt bar ran at roughly one frame a second. The log now holds the last twenty messages. Older ones are prepended as the reader scrolls up, so nothing is lost: State.chatHistory still carries all of them, and switching conversations, sending and the typing dots behave as before. The scroll position is held by anchoring to the row being read, not by adding the change in scroll height, which said only how much taller the log had become and not where in it the reader was. Browser scroll anchoring is turned off on .chat-log, because it corrects the same scrollTop at the same moment and the two corrections fought, leaving the reader back at the start of the log.
|
Thanks for the detailed reply! One correction that matters for the adaptation: the Since you can't build on Linux, I'm happy to run a Linux check ( |
95aed55 to
c44b9d4
Compare
|
Ran the Linux check on the current head (
Happy to re-run it whenever you or @Louis-CFM push the |
Thanks for the push back — confirmed against current main: L304 is Some((0.0, 0.0, STRIP_W, STRIP_H)), and it does need to become the new collapsed size, since this branch renames those constants to HIDDEN_W/HIDDEN_H (80×24). The line itself only runs where there's no global cursor poll — Linux — but the missing-constant error would fail cargo check on any platform after a merge, so it needs fixing regardless. Yes please to the Linux check. Our merge base is 21 commits behind current main, so #111 is newer than what we merged; a check against current main would cover more. I may end up doing another upstream merge to resolve conflicts, since this PR is already very large and I'm not sure it complies with the contribution rules. I'll tag you if I do. |
Linux cfg gates for the opencode server, chat and island popup paths, so the merge no longer breaks cargo check on Linux (arreina's report on Louis-CFM#47). The opencode plugin dir and the chat's working dir go through platform::home_dir too, instead of reading USERPROFILE and silently landing on "." elsewhere. style.css: revert upstream's .view:not(.on) animation pause, and let html/body shrink to their content instead of claiming the viewport. The sizing half is a HACK for the Chromium-based webview, not the final fix, and says so in the CSS. The proper fix is to put the island back in flow and move the horizontal offset out of its transform onto the parent. island.rs: drop COMPACT_W/EXPANDED_W, which nothing but the tests read; the tests now carry their own list of island widths. Auto-hide now slides the island up past the top edge instead of blinking it out with opacity 0: the bar shrinks and rises on the same 340ms closeCurve the pop-up runs in reverse, and Mochi rides out with it rather than vanishing on the first frame. The slide is re-checked every frame (like the fade it replaces) because the FSM reaches the off-screen state from hidden, where setMode returns early.
…e branch Upstream moved three commits past 28d045c: the GitHub contribution grid, the 0.1.4 bump and Mochi's wardrobe with its physics. All of it is NotchBuddy/, tests/, scripts/ and docs - nothing under windows/ - so this is a straight merge with no overlap with the Windows work in this branch. Merging it also surfaced a problem that only the reduced bar had: Mochi's gaze stopped following the pointer there and worked everywhere else. Two frame-loop faults, both invisible in the modes where the loop runs continuously: - dt was clamped only from above. ensureRunning stamps lastFrame with performance.now(), but the loop is resumed by requestAnimationFrame, whose callback timestamp is the vsync time and can be older than that stamp, so the first frame after a resume had a negative dt. Every smoother downstream reads dt as a rate, so 1 - 0.0025^dt went negative and yaw ran away from its target at roughly 1.13x per frame instead of towards it. - A reduced bar that is still on screen now counts as live. Mochi is drawn by the frame loop, so a parked one left the last painted frame as all there was, and each cursor event bought a single frame before parking again. Watching him look means moving the pointer off an 80x24 bar, so the island's visibility decides, not the pointer's position. Off-screen still parks completely. Upstream's `.view:not(.on)` animation pause (eb1241f) is reverted and re-scoped rather than dropped. WebView2 mis-rasterises the composited island when animations inside it stop mid-flight - Mochi came back the wrong size or not at all - and `animation: none` is affected identically, so what matters is the stopping, not the pausing. Pausing only while the island is retracted (`#island[data-hidden] *`, with the attribute set where the mode is already known) keeps the win upstream was after - a webview with nothing running while it sleeps - in the one state where nothing of the island is on screen to be seen pausing. Verified on Windows before pushing, with the commands CI itself runs: cargo build --release -p coucou-hook cargo test --workspace 24 + 3 tests, 1 ignored, no compiler warnings npm run build tsc --noEmit + vite build npm run pack NSIS installer built, installed and launched; opencode serve came up on port 47821 Not verified here: the Linux job in .github/workflows/linux.yml, which needs a Linux runner - WSL is not installed on this machine - and the Swift tests in build.yml, which need Xcode. Nothing in this branch should reach either job: the windows crate is a cfg(windows) dependency, so every Win32 call, CommandExt import and taskkill is behind a gate or lives in platform/windows.rs, and this merge changes no file under windows/.
|
@arreina Could you re-run the check against All 17 errors came from the opencode integration being written Windows-only. Each is now behind a gate or routed through Three changes are shared rather than Windows-only, in case they're worth a look:
|
|
Re-ran it on On the three shared changes:
|
Upstream moved five commits past b5d2242 while this PR was up for review: greeting v2, Mochi on the desktop, the 0.1.5 and 0.1.6 version bumps. All of it is NotchBuddy/, tests/, scripts/ and docs - `git diff b5d2242 59f63df -- windows` is empty - so this brings the porting source up to date without touching the Tauri app, and PR Louis-CFM#47 stays frozen at d92c1ba. This branch is now the integration line: merge upstream into it, then push it to the fork's main and cut per-feature branches for anything ported from macOS.
Upstream moved five commits past b5d2242 while PR Louis-CFM#47 was up for review: greeting v2, Mochi on the desktop, and the 0.1.5 and 0.1.6 version bumps. All of it is NotchBuddy/, tests/, scripts/ and docs - `git diff b5d2242 59f63df -- windows` is empty - so this brings the porting source up to date without touching the Tauri app. PR Louis-CFM#47 stays frozen at d92c1ba.
The island can rest anywhere along the top of the screen: drag the bar, or drag the open island by its top band (the header, away from the tabs and buttons), and it stays where it is let go, across restarts. A press that doesn't move is still a click. Settings → General → Island position offers Left / Centre / Right. The position is a fraction, 0..1. Rust slides the window by position × (screen − window) and the island slides inside it by position × (window − island), so it lands at position × (screen − island) whatever its size: never off the display, and at an edge it opens inward. The hidden island's wake strip follows it, and the display the island is on stays put while it is dragged. Taken from upstream Louis-CFM#47's drag (Imanol Diaz), without the rest of that commit, and with three differences: the position is kept at launch; the drag works in logical pixels (screenX and window.screen already are); moves don't write the settings file until release, which also tells the settings window so its copy can't put the old value back. The open island's header also gets a Reduce button (–) that folds it back into its bar right away, like Escape, and hides while an alert is waiting for an answer. Co-Authored-By: Imanol Diaz <imadiaz01@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Windows: enhanced opencode integration (sessions, persistent server), island revamp (drag, compact, hide), small corrections and bug fixes
What this is actually about
#19 gave the island its opencode integration: the plugin, the session pill, and a
chat that shells out to
opencode run. The chat never had integrated commands atall — there was no command list, no way to run one, no session picker and no
history. Those are added here, not repaired.
The reason is that they cannot exist without something to ask. opencode's command
list, its sessions, and the messages inside those sessions are only exposed by a
long-lived HTTP server, and that server exists solely as a side effect of someone
running the TUI. With no TUI open, the island had no way to learn that
/init,/reviewor any of the user's installed commands existed, and no way to reach anexisting conversation at all.
So the managed server here is not a standalone convenience — it is the missing
half of #19, and the foundation everything else in this PR stands on:
/commandsneeded a server to ask. That is what the empty slash menu was.the chat window opens on the conversation instead of blank.
Each of those is a consequence of the server existing, not a parallel change. The
toggle at the end is the one genuine user-facing choice: keep one server for the
lifetime of the app, or keep none and start one only when something asks.
The other half is the island revamp, which is independent of the server: four widths
instead of two, three independent timers instead of one, the agent pills tied to the
width rather than the mode, and the display picker gaining a secondary option. Those
are described on their own below, along with a set of corrections to behaviour that was
wrong rather than missing.
The chat window itself came third. Everything above made a command list and a session
picker possible; making them usable was separate work, and is described under
Chat window.
Scope: the Windows app, and the shared build
Every file this PR changes is under
windows/, plus the repository.gitignore, and allof it was built and exercised on Windows. The macOS app in
NotchBuddy/is untouchedbeyond merging
main, and its chat is a different implementation entirely, so it has noequivalent of the command menu, session picker or history yet.
windows/is not a Windows-only app, though —linux.ymlbuilds the same Tauri app forLinux — so whatever lands here has to keep that build compiling. The opencode integration
in this PR was written Windows-first and did not: @arreina's
cargo check --workspace --all-targetsagainstc44b9d4came back with 17 errors. Theplatform adaptations are what put it back, and they are
deliberately thin — gates and calls through
platform, with nothing added that pretendsto be a Linux implementation.
NotchBuddy/, which this PR does not touchbeyond merging
main. Its chat is a different implementation entirely, so it has noequivalent of the command menu, session picker or history yet, and none of Mochi's new
wardrobe.
Upstream merged
mainhad moved well past this branch, so it is merged in rather than left to drift.Two merges, both clean:
e7b758ebrings in upstream to28d045c(GitHub alerts, CI status and reviews inthe notch).
d92c1babrings inb5d2242— the GitHub contribution grid, the 0.1.4 bump, andMochi's wardrobe with its physics.
git diff 28d045c b5d2242 -- windowsis empty: those three upstream commits areNotchBuddy/,tests/,scripts/and docs, so bringing them in cannot have touchedthe Tauri app. That is also why the diff against
mainis now exactly this PR's ownwork.
Platform adaptations
What the Windows work needed so that the shared build still compiles.
28d045cbringsupstream's own Linux work into this branch, so
linux.ymlruns on this PR. Against thehead this PR had when @arreina looked at it —
c44b9d4— hiscargo check --workspace --all-targetscame back with 17 errors, every one of them fromthe opencode integration being Windows-only. Each is now either behind a gate or routed
through
platform:island.rs) asks Win32 whether the windowunder the pointer is one of our own native select menus. Only Windows has such popups
and only Windows can answer, so the query is
#[cfg(windows)]and elsewhere nothing isexempt.
opencode_server.rs) isGetExtendedTcpTable. It is#[cfg(windows)], with a#[cfg(not(windows))]stub returning an empty list. Thekill_pidbeside it is gated the same way — and is unreachable on Linux anyway, sincean empty table means no port is ever attributed to a pid.
std::os::windows::process::CommandExtandCREATE_NO_WINDOWin three places. All three now go throughplatform::no_console,which is a no-op elsewhere.
opencode.rsusedGetLocalTime, nowplatform::local_time().USERPROFILEdirectly, which compiles on Linux but resolves tonothing: the opencode plugin directory, the chat's working directory and the first-turn
context. They now use
platform::home_dir(), which readsHOMEthere. The twoexecutable-probing sites that also read it were left alone deliberately — they look for
.bun, scoop andopencode.exelayouts, so on Linux the block simply never matches andplatform::find_on_pathalready covers the case.Nothing here adds a Linux implementation. Where the Windows way cannot work on Linux the
code says so and returns empty rather than pretending.
This has not been re-checked on Linux yet. @arreina's report was against
c44b9d4;this branch has since merged
main, fixed those errors and changed more besides, andthere is no Linux runner on the machine this was built on. What the fixes rest on is that
the
windowscrate is acfg(windows)dependency — so every Win32 call,CommandExtimport and
taskkillis either behind a gate or insideplatform/windows.rs, and afuture ungated use becomes a Linux-only compile error rather than a silent one. That is an
argument, not a green run, and his re-run is the check that matters here.
Mochi on the reduced bar, WebView2, and the retreat
Merging
mainsurfaced three things that only the Windows app shows. Two areWebView2-specific — the island is a transparent, always-on-top window whose size and
position change every frame — and the third is a bug in the frame loop that only had
one opportunity to fire.
The page box, and a marked hack
Upstream's viewport and island changes left
html/bodyclaiming the whole window, whichdrags the island's own box out of shape and breaks Mochi's animations on WebView2. Letting
them shrink to their content fixes it. This is a workaround, and it is labelled as one
in the CSS, with the proper fix written next to it: put the island back in flow and move
the horizontal offset out of its transform onto the parent, so the page's box is the
island's box rather than merely not fighting it. It should be done properly before this
reaches a release, and the hack is deliberately loud about that.
The animation pause, re-scoped rather than dropped
eb1241fpauses animations inside views that are not on, so an infinite animation in atransparent view stops repainting the webview every frame. As written it visibly breaks
Mochi on WebView2 — the island comes back wrong-sized or not at all, and the retreat and
wake animations stop making sense. Both
animation-play-state: pausedandanimation: nonereproduce it, and scoping the rule to the four elements that actuallyanimate does not help either, which points at the composited island layer being re-rastered
when animations inside it stop mid-flight rather than at anything CSS means.
So it is kept, but only where it cannot be seen: while the island is retracted, and only
then.
The attribute is set where the mode is already known. Nothing inside the island is visible
in that state, so the pause cannot be observed, and the win upstream was after — a webview
with nothing running while it sleeps — is kept. @arreina's measurement of two running
animations at rest should still hold on Linux, which this does not affect either way.
Mochi's gaze, which only failed in the reduced bar
Mochi follows the pointer in every state, on Windows and on macOS alike, using the same
tanhof the distance to him on both. In the reduced bar he stopped following it, and thecause was a frame-time sign error rather than anything about that state.
The loop stamped
lastFramewithperformance.now()but resumed throughrequestAnimationFrame, whose callback timestamp is the vsync time and can be olderthan that stamp. The first frame after a resume therefore had a negative
dt. Everyexponential smoother in the engine reads
dtas a rate, so1 - 0.0025^dtwent negativeand
yaw += (tgYaw - yaw) * kLookmoved away from its target at roughly 1.13× per frameinstead of towards it.
yawis a bounded angle — every target is inside ±0.62 rad — so itshould be impossible for it to reach -6.62, and it did exactly that, spinning the head
through more than a full turn.
Only the reduced bar was affected because it is the only state where the loop parks: with
the geometry settled there is nothing to animate, so the loop stops, and the cursor poll
restarts it for a single frame at a time. That is right for an island nobody can see, but
it is also what gave the negative
dta chance to land on nearly every frame. Two changes:dtis now clamped at both ends. The upper clamp was always there so a dropped framecannot teleport the island; the lower one is what was missing.
loop, so a parked one left the last painted frame as all there was, and watching him look
means moving the pointer off an 80×24 bar. Off-screen still parks completely, so the
zero-cost-when-nothing-is-drawn behaviour is kept where it applies.
A side effect worth knowing:
engine.busycountsyawas unconverged, so while it wasdiverging the loop could never park in the expanded or compact states either.
Auto-hide slides off-screen
Auto-hide used to set the island's opacity to 0, which took the bar away but did not
explain where it went, and left Mochi blinking out on the first frame. The island now
slides up past the top edge on the same 340 ms curve the pop-up uses in reverse, with
Mochi riding out inside it, and springs back when it is recalled. The window does not
move, so the body clips it for the whole slide, and the poll keeps its own hit band at the
resting position so "hover to restore" still finds a bar that is no longer on screen.
Prior work used as a starting point
opencode runas a chat backendopencode runis still correct for slash commands and attachments. Its session model is also what the picker and history build on.windows/files.layout.ts(NO_NOTCH_W/NO_NOTCH_H, 240 pt bar) so Windows matches macOS, andrestingLayout()mirrors the same values. It contributes no drag handling and no stored position: the bar is positioned independently here, and the drag, the stored resting place and the live repositioning are not derived from that PR.Island revamp
Four widths, three timers
The island had two widths and one timer. It now has four and three, each step with its
own setting, so the resting size is a choice rather than a consequence:
autoCloseInterval)absenceInterval)autoCloseDelay)A fourth state machine state,
gone, replaces an offscreen flag that used to beinferred from a delay being non-zero, so off-screen is a real state rather than a
side effect of a setting.
Agent pills follow the width
The pills sit in the 240 bar — Mochi on the left, pills filling the right end — and
never in the expanded panel, where the grid is a child of the island and was painting
mini Mochis over the settings view and every other full-screen view. The 80pt bar is
the single centred Mochi and nothing else.
Gestures
Each of these is a distinct gesture on a distinct state, and none of them opens the
panel by accident:
Near or far is decided natively, from the press position and the same rectangle the
click-through test uses, and the webview reports clicks that land on the window but not
on the island — so the gestures do not depend on the cursor having moved recently.
Resting position
the display, so a drag lands anywhere while the edge presets still snap exactly. A
drag holds the island on its current display for its duration, so a bar dragged
toward an edge cannot hop monitors mid-gesture.
island is drawn inside it at a horizontal offset (
islandOffsetX), so a bar restingat either edge grows out of that edge. The same offset feeds the island's hit
rectangle, so the interactive area tracks what is drawn. The island was previously
always centred in the window regardless of where the bar sat.
Custom when a drag leaves it between two of them.
Toggles
auto-close timer until unpinned. Pinned states survive a dismissal; your own pin
setting is never cleared by one.
Settings
rather than picked from a list — 5 to 60 seconds in 5s steps, with the current value
shown and the row dimmed when the step is Off or Never. Auto-close is the original
interval; Compact and Auto-hide were fixed or absent.
after them.
display or the one under the cursor. The secondary is the first non-primary display
in OS order, falling back to the primary on a single-monitor machine.
background goes with the Mochi rather than being left behind on the edge, and the
retreat reads as the pop-up backwards — see
Auto-hide slides off-screen.
Display selection
The cursor preference now resolves from the setting on every placement rather than
only before the island had a home, so moving the pointer to another display moves the
island with it. A drag holds the island on its current display for its duration, so the
gesture cannot fight the drag and hop it across a boundary.
Additional features and corrections
The hidden wake strip, and #28
While reduced, the island used to shrink the window to a fixed 240×6 strip parked
at the top centre and treat that strip as the only thing the OS could hit. That
strip is removed. The island element is its own target, so there is no separate
hidden geometry left over from when the island was always centred.
This is the mechanism #28 is about.
Two things changed together, and both matter:
cursor poll keeps running while reduced instead of parking. Parking is what
froze the click-through flag in Windows: hidden island never wakes on mouse hover #28's report.
forget_ignore_state(), so thepoll re-issues the setter rather than trusting a cached value that a resize
invalidated.
So #28's failure condition — a stale
WS_EX_TRANSPARENTleft queued while the pollis parked — does not arise here. That is a consequence of removing the strip and
keeping the poll alive, not a separate backport of #28's patch. If #28 is still
wanted for a genuinely notched screen, it is unaffected and should be handled
there.
Bringing the island back from the reduced state is the double-click described under
Gestures above.
Corrections
Behaviours that were wrong rather than missing. Each was found by exercising the
feature on a real machine, not by reading the code.
window but not on the island had no listener — that handler was registered only for
browser debugging — so the gesture relied entirely on the native poll, which is
itself conditional on the cursor having moved recently. It now works from both, and
a click that arrives twice is debounced so one click cannot read as a double-click.
re-armed when the cursor left the bar. With wake on hover on, the cursor is already
over the bar when the panel collapses, so the timer was cancelled and never re-armed
— the bar sat at 240 until the pointer happened to move off it. The timer now runs to
completion regardless of the cursor. Hovering still shows the bar at 240 when wake on
hover is on, but that is
setBarHoverwidening it, not the timer being held off —the two are separate and the bar still reaches 80 on its own.
forces the fully-compact bar to render at the full 240pt width, so a click that
compacted it while the cursor rested on it reached the right state and kept the wide
pixels. The flag is now dropped when the bar actually compacts.
the island's rectangle to answer that, from a cursor position refreshed only as the
pointer moved — so a click after the mouse had been still could be judged as landing
near the bar. The classification now happens natively, at the press, from the same
rectangle the click-through test uses.
was pinned to whatever display the island already sat on, which made the preference
only take effect before it had a home. That guard existed to stop a drag hopping
displays; it is now scoped to the drag itself.
never emptied, and the element is built once and reused, so each narrowed render
appended under the previous rows. The filter itself was always correct.
already-newest-first response.
collapse()while theisland was already on the bar, which the state machine ignores as a transition to
its current state, so it did nothing at all.
Opencode integration
The managed server
The foundation the rest of this PR sits on. The command and session lists only
exist on a long-lived HTTP server. With no TUI running there is none, so the slash
menu opened empty. Coucou now reuses a server that is already running and otherwise
starts its own on a fixed port.
The load-bearing detail:
opencode servescopes both the session list and theproject commands to its working directory. Inheriting Coucou's own directory
answered with an empty session list and a fraction of the installed plugins;
started in the user's home it matches the TUI's server exactly. Passing
?directory=on the request does not change this.Also handled:
read as absent.
reach discovery at once; startup is now serialised and one caller reuses the
other's server instead of losing the bind.
binary as a second process and made the child handle useless for cleanup —
shutdown now goes by whoever holds the port.
Chat over that server
Every message ran
opencode run, which boots a throwaway server, does one turnand tears it down. That left the command and session lists reading a different,
long-lived server than the one answering the chat — which is why one worked while
the other did not. A turn is now a pair of HTTP calls against the running server.
opencode runis kept where it is genuinely the right tool: a slash command ownsits whole turn, and an attachment needs
--file, which the server route wouldmean reimplementing as an upload. A failed server turn logs and falls back rather
than losing the message. With the provider set to Claude and no key stored, the
turn goes to opencode instead of stopping on "API key missing".
Persistent server toggle
In the opencode section of the settings window.
and is reaped once nothing has used it for a while, so the lists work without
leaving a process behind.
Switching it takes effect immediately rather than at the next launch, and turning
it on mid-conversation keeps the server already answering instead of replacing it
out from under the chat.
Session handling: listing, history, and deleting
All three of these read from the managed server, which is why they arrived with it.
response, so the oldest session appeared at the top. It now sorts by creation
time, so it cannot depend on the server's ordering holding.
the chat window, instead of opening blank. Tool-only turns are skipped so they
cannot render as empty bubbles.
row would otherwise destroy a conversation. Deleting the session you were
chatting into resets the chat rather than leaving it pointed at something gone.
/sessionsopens the picker rather than replying with text telling you topick one, which was a dead end.
Slash menu filtering
The menu appeared to ignore typing and always show every command.
closePopups()detached the menu but never emptied it, and the element is builtonce and reused — so the rows from the first render stayed as children and each
narrowed render appended underneath them. The filter itself was correct
throughout; it was writing into an element still holding the previous list.
Filtering is also now synchronous rather than queued behind the command fetch, so
a keystroke never waits on a request a previous keystroke started.
Chat window
The slash menu listed commands, but the window around it had several defects that
made the whole thing feel broken. They were found by using it, not by reading it —
several were invisible in the diff and only showed up as something that did not
respond.
The command menu
always highlighted, so Enter completed the command instead of submitting what was
typed, and every command took two presses. It now submits when the text already
spells the command, and only completes when it does not.
took focus and the input stopped receiving key events. They are divs now.
loop whenever the list came back empty — which a server still starting does — and
every later request queued behind it. It retries once, then says so.
closing the panel. Both now dismiss them.
/modelsIt answered with a wall of text that could not be selected from. It opens a picker
now, grouped by provider with each provider's configured default first, and the
model chosen is sent with the turn rather than merely displayed.
Nothing is truncated. The list was previously cut at 40 items, which one provider
filled on its own — so the free opencode models never appeared at all.
Reading the server's answer was the other half of that.
/config/providersreturnsprovidersas an array of{ id, models: { <model-id>: … } }, and both levelshad been read the wrong way round, so the picker came up empty against a server with
120 models to offer.
Escape cancels a turn
The first press arms it and says so; the second gives up on the reply.
The island stops waiting immediately, and the session ends up with the prompt and no
answer — checked against a separate opencode instance while developing, which is
where the behaviour was confirmed rather than assumed.
Validation
Run on Windows, with the commands the CI workflows themselves run:
cargo test --workspace— 24 passed incoucou, 3 incoucou-hook, 1 ignored,green both with the app running and with it closed.
cargo checkis warning-free.npx tsc --noEmitclean,npm run buildclean.npm run pack— the NSIS installer builds, installs silently per-user, and theinstalled
coucou.exelaunches and bringsopencode serveup on port 47821.the suite free of network and credential requirements. Run it with
cargo test -p coucou --lib -- --ignored.git diff --checkclean.Not verified here, and worth saying plainly:
cargo check --workspace --all-targetsandnpm run packfor Linux need a Linux runner, and WSL is notinstalled on the machine this was built on, so the platform
adaptations are argued from the source and the
gates
rather than from a green run. @arreina's report was against
c44b9d4, which isseveral fixes and two merges back from this head.
build.yml(screen geometry, link filter, chat parsing, plangauge) need Xcode. The merge brings in upstream code that already passes them there.
Also checked by hand in the installed app: commands and sessions appear with no TUI
running, exactly one server is ever listening, chat completes without a process per
message, and the toggle behaves correctly in both directions across restarts. The
auto-hide slide, the four widths, the WebView2 layout workaround, the narrowed
animation pause and Mochi's gaze in the reduced bar were each exercised on the real
island, the last two against the measurements quoted above rather than by eye alone.
The gesture and display work was checked by hand on a two-display machine, exercising
each gesture from each of the four widths. The near/far classification and the
pointer-following display were both invisible to reading the code and only showed up
as a press landing on the wrong side of a stale rectangle, so they were diagnosed from
traces of the press, the state transition and the island rect. That tracing was
temporary and is not part of the commits.
Known limitations
while the island is retracted. If the WebView2 re-raster can be fixed at the source, the
original rule should go back in — see
The animation pause, re-scoped.
the workaround; the real fix changes how the island is positioned and is not in this
branch — see The page box, and a marked hack.
chosen display. On a three-display machine the picker does not say which is which.
island is on. Reported against
7ee64b8: after dragging the island by its header, aChrome window on the same display intermittently does not respond to a press-and-drag
on its title bar - it either refuses to move, or only begins moving once the pointer
is jiggled. Other applications tested drag normally, and it has not been reproduced
on a display without the island, so it looks specific to Chrome's custom title-bar
hit-testing rather than to the island's own hit area, which the report notes is far
from where the drag was attempted. The cause is not yet identified: the island's
click-through flag is toggled by its own cursor poll, and instrumenting that poll
showed the flag flipping several times a second, but reducing the window churn behind
it did not change the behaviour. It is recorded here rather than fixed because the
interaction is not yet understood, and guessing at it has already produced several
fixes that did not hold. Chromium-based applications other than Chrome have not been
tested, and whether it survives an app restart is also unknown.
Known gap in test coverage
The parallel-lookup behaviour — two simultaneous command/session fetches must not
both try to bind the port — was verified by observing that exactly one listener
exists afterwards. An automated version has to stop and restart the managed port,
which fights a running instance of the app for that same port, so it was left out
rather than committed as a flaky test. Making it reliable needs the port to be
injectable.