Skip to content

Windows: enhanced opencode integration (sessions, persistent server), island revamp (drag, compact, hide), small corrections and bug fixes - #47

Open
imanoldiaz wants to merge 23 commits into
Louis-CFM:mainfrom
imanoldiaz:feat/opencode-server-integration
Open

imanoldiaz wants to merge 23 commits into
Louis-CFM:mainfrom
imanoldiaz:feat/opencode-server-integration

Conversation

@imanoldiaz

@imanoldiaz imanoldiaz commented Oct 1, 2026 •

Copy link
Copy Markdown

Windows: enhanced opencode integration (sessions, persistent server), island revamp (drag, compact, hide), small corrections and bug fixes

Based on current main, continuing #19. This branch merges main as it now
stands (b5d2242), so the PR diff against main is only the files under
windows/. Reading it as a commit range is no longer useful: 05e9fc5..d92c1ba
spans 77 commits, and 62 of them are upstream's.

The opencode integration from #19 is the base of this branch; this PR is what it
became once there was a server to ask. This PR's own work is thirteen commits plus
two merges:

7987bb2 Windows: manage an opencode server for commands and sessions
262aad2 Windows: send chat turns to the running server instead of a process per message
50a583b Windows: session picker ordering, delete, history, and slash filtering
f4ffced Windows: persistent opencode server toggle in the opencode settings section
62df62f Windows: styles for the session row delete affordance
25fb3db Windows: four-step island retraction with independent timers
c43ad04 Windows: document that the cursor display preference does not follow the pointer
2c23411 Windows: follow the pointer for the cursor display preference
c0abe58 Windows: make the outside click reach the island from either direction
05d92ad Merge upstream main into the Windows opencode branch
1f5a0d0 Windows: a usable chat window - commands, model picker, and Escape
7ee64b8 Windows: let the open island be dragged by its header, and drag in logical pixels
c44b9d4 Windows: hold only the tail of the conversation in the log
e7b758e Merge upstream main, plus the Linux cfg gates, the WebView2 layout fix and the auto-hide slide
d92c1ba Merge upstream main again, for v0.1.4 — plus Mochi's gaze fix and the narrowed animation pause

c43ad04 only records a limitation that 2c23411 then fixes, and is worth
squashing into it before merge.

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 at
all
— 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,
/review or any of the user's installed commands existed, and no way to reach an
existing 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:

  • /commands needed a server to ask. That is what the empty slash menu was.
  • The session picker needed a server to list sessions from.
  • Session history needed a server to read a picked conversation's messages, so
    the chat window opens on the conversation instead of blank.
  • Deleting a session needed the same server.
  • Chat itself moved onto it, which is what removed the process-per-message.

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 all
of it was built and exercised on Windows. The macOS app in NotchBuddy/ is untouched
beyond merging main, and its chat is a different implementation entirely, so it has no
equivalent of the command menu, session picker or history yet.

windows/ is not a Windows-only app, though — linux.yml builds the same Tauri app for
Linux — 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-targets against c44b9d4 came back with 17 errors. The
platform adaptations are what put it back, and they are
deliberately thin — gates and calls through platform, with nothing added that pretends
to be a Linux implementation.

  • macOS is the native Swift app in NotchBuddy/, which this PR does not touch
    beyond merging main. Its chat is a different implementation entirely, so it has no
    equivalent of the command menu, session picker or history yet, and none of Mochi's new
    wardrobe.

Upstream merged

main had moved well past this branch, so it is merged in rather than left to drift.
Two merges, both clean:

  • e7b758e brings in upstream to 28d045c (GitHub alerts, CI status and reviews in
    the notch).
  • d92c1ba brings in b5d2242 — the GitHub contribution grid, the 0.1.4 bump, and
    Mochi's wardrobe with its physics.

git diff 28d045c b5d2242 -- windows is empty: those three upstream commits are
NotchBuddy/, tests/, scripts/ and docs, so bringing them in cannot have touched
the Tauri app. That is also why the diff against main is now exactly this PR's own
work.

Platform adaptations

What the Windows work needed so that the shared build still compiles. 28d045c brings
upstream's own Linux work into this branch, so linux.yml runs on this PR. Against the
head this PR had when @arreina looked at it — c44b9d4 — his
cargo check --workspace --all-targets came back with 17 errors, every one of them from
the opencode integration being Windows-only. Each is now either behind a gate or routed
through platform:

  • The popup exemption in the cursor poll (island.rs) asks Win32 whether the window
    under 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 is
    exempt.
  • The loopback port table (opencode_server.rs) is GetExtendedTcpTable. It is
    #[cfg(windows)], with a #[cfg(not(windows))] stub returning an empty list. The
    kill_pid beside it is gated the same way — and is unreachable on Linux anyway, since
    an empty table means no port is ever attributed to a pid.
  • Process spawning used std::os::windows::process::CommandExt and
    CREATE_NO_WINDOW in three places. All three now go through platform::no_console,
    which is a no-op elsewhere.
  • Timestamps in opencode.rs used GetLocalTime, now platform::local_time().
  • Three paths read USERPROFILE directly, which compiles on Linux but resolves to
    nothing: the opencode plugin directory, the chat's working directory and the first-turn
    context. They now use platform::home_dir(), which reads HOME there. The two
    executable-probing sites that also read it were left alone deliberately — they look for
    .bun, scoop and opencode.exe layouts, so on Linux the block simply never matches and
    platform::find_on_path already 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, and
there is no Linux runner on the machine this was built on. What the fixes rest on is that
the windows crate is a cfg(windows) dependency — so every Win32 call, CommandExt
import and taskkill is either behind a gate or inside platform/windows.rs, and a
future 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 main surfaced three things that only the Windows app shows. Two are
WebView2-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/body claiming the whole window, which
drags 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

eb1241f pauses animations inside views that are not on, so an infinite animation in a
transparent 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: paused and
animation: none reproduce it, and scoping the rule to the four elements that actually
animate 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.

#island[data-hidden] * {
  animation-play-state: paused;
}

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
tanh of the distance to him on both. In the reduced bar he stopped following it, and the
cause was a frame-time sign error rather than anything about that state.

The loop stamped lastFrame with performance.now() but resumed through
requestAnimationFrame, whose callback timestamp is the vsync time and can be older
than that stamp. The first frame after a resume therefore had a negative dt. Every
exponential smoother in the engine reads dt as a rate, so 1 - 0.0025^dt went negative
and yaw += (tgYaw - yaw) * kLook moved away from its target at roughly 1.13× per frame
instead of towards it. yaw is a bounded angle — every target is inside ±0.62 rad — so it
should 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 dt a chance to land on nearly every frame. Two changes:

  • dt is now clamped at both ends. The upper clamp was always there so a dropped frame
    cannot teleport the island; the lower one is what was missing.
  • A reduced bar that is still on screen now counts as live. Mochi is drawn by that
    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.busy counts yaw as unconverged, so while it was
diverging 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

PR Author What it contributed How it was changed here
#19 @igorroc The base for everything: the opencode plugin, the island pill, and opencode run as a chat backend Kept as the base. The CLI-chat half is reworked rather than replaced — opencode run is still correct for slash commands and attachments. Its session model is also what the picker and history build on.
#31 @eco-null Emitting an outside-click event for presses outside the painted island bounds, keeping pinned cards open and leaving the auto-close timer intact when no click occurs Extended, not replaced. Its outside-click event is now driven from two sources — the native poll and the webview — so the click works whether it lands inside the window or on the desktop, and near/far is decided natively from the same rectangle the click-through test uses. One outside click compacts to the 240 bar, a second takes it to 80, and the reduced bar ignores a lone near click. The fully-compact timer is no longer gated on the cursor being off the island, so it now always runs.
#22 @Kamasoutra Compact resting bar on displays without a notch. macOS only — it touches no windows/ files. Mirrored for geometry, extended for interaction. Its no-notch resting sizes are reproduced in layout.ts (NO_NOTCH_W/NO_NOTCH_H, 240 pt bar) so Windows matches macOS, and restingLayout() 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.
#40 @Louis-CFM Documentation conventions for no-notch support No code change.
albertmca's work on moving the island while it is open @AlbertMcAvoy The idea that the island can be dragged by its header when expanded, rather than only as a resting bar. His commit already credits this PR's drag as its starting point, and carries the logical-pixel correction too. Inspired by, and deliberately diverged from. Nothing was cherry-picked: his change touches ten files and conflicts with five here, and two of its ideas belong to other open PRs. Only the drag-while-open idea and the pixel fix were taken, and the drag differs in the details. His grabs anywhere on the header row on a press-and-hold; this one measures the header's actual bounds and leaves buttons, inputs and links alone. His keeps the position from the moment the island opened; this one normalises across the display the island is on, so it behaves the same on a second monitor. #150 already covers dragging the island anywhere and #145 a minimise button, so nothing here overlaps either.

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:

Step Setting
expanded 640 → 240 Auto-close (autoCloseInterval) closes the open panel to the retracted bar
240 → 80 Compact (absenceInterval) the tiny centred Mochi, no pills
80 → off-screen Auto-hide (autoCloseDelay) from either resting bar

A fourth state machine state, gone, replaces an offscreen flag that used to be
inferred 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:

Wake on hover Widens the 80pt bar to 240 and brings its pills on. Never opens the panel, and never recalls the island from off-screen.
Hover to restore The only gesture that brings the island back from off-screen. Gated on Auto-hide, since with that on Never nothing ever leaves the screen.
Double-click near Widens the 80pt bar to 240, within 56px of its painted rect measured on all four sides so left and right are identical by construction. A single near click does nothing, so a stray desktop click cannot wake it.
Click outside One click compacts the panel to the 240 bar; another takes it the rest of the way to 80, skipping the wait.
Click the bar Opens the panel.

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

  • Drag to place. Dragging the compact bar stores where it landed, normalised across
    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.
  • Expands from where it rests. The window is always the full panel width and the
    island is drawn inside it at a horizontal offset (islandOffsetX), so a bar resting
    at 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.
  • Left / Centre / Right / Custom. The saved fraction maps onto presets and becomes
    Custom when a drag leaves it between two of them.

Toggles

  • Pin, in the island header. A pinned island ignores outside clicks, Escape and the
    auto-close timer until unpinned. Pinned states survive a dismissal; your own pin
    setting is never cleared by one.
  • Wake on hover and Hover to restore, as above.

Settings

  • A ratio slider per timer. Each of the three retraction delays can be dialled in
    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.
  • Volume slider and mute, unchanged, and now alongside the timers rather than
    after them.
  • Secondary display in the monitor picker, which previously offered only the main
    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.
  • Auto-hide slides the island off the top edge rather than fading it out, so the bar's
    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:

  • The reduced window is a real 80×24 island rather than an invisible strip, so the
    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.
  • Collapsing now clears click-through and calls forget_ignore_state(), so the
    poll re-issues the setter rather than trusting a cached value that a resize
    invalidated.

So #28's failure condition — a stale WS_EX_TRANSPARENT left queued while the poll
is 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.

  • Clicks on the island reached it only sometimes. A click landing on the island
    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.
  • The fully-compact timer stopped running. It was cancelled on hover, and only
    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 setBarHover widening it, not the timer being held off —
    the two are separate and the bar still reaches 80 on its own.
  • A click that compacted the bar could leave it looking unchanged. The wake flag
    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.
  • Near and far were decided from a stale cursor position. The front end rebuilt
    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.
  • The cursor display preference never worked past the first placement. The monitor
    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.
  • The slash filter was writing into a stale element. The menu was detached but
    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.
  • Session rows rendered oldest-first, because the list was reversed on top of an
    already-newest-first response.
  • Two no-op transitions. The second outside click called collapse() while the
    island 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 serve scopes both the session list and the
project 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:

  • The per-probe timeout was short enough that a server still loading its config
    read as absent.
  • The command and session lists are fetched together, so on a cold start both
    reach discovery at once; startup is now serialised and one caller reuses the
    other's server instead of losing the bind.
  • 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 —
    shutdown now goes by whoever holds the port.
  • The port is claimed without a console window appearing.

Chat over that server

Every message ran opencode run, which boots a throwaway server, does one turn
and 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 run is kept where it is genuinely 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 instead of stopping on "API key missing".

Persistent server toggle

In the opencode section of the settings window.

  • On — one server runs from launch until the app quits.
  • Off — none is kept. One starts on demand for the command and session lists
    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.

  • Newest first. The list was reversed on top of an already-newest-first server
    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.
  • History on selection. Picking a session reads its messages and shows them in
    the chat window, instead of opening blank. Tool-only turns are skipped so they
    cannot render as empty bubbles.
  • Delete from each row, behind a two-step confirm — a stray click on a small
    row would otherwise destroy a conversation. Deleting the session you were
    chatting into resets the chat rather than leaving it pointed at something gone.
  • /sessions opens the picker rather than replying with text telling you to
    pick 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 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 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

  • Enter ran nothing. The menu is open from the first character and a row is
    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.
  • Arrows did nothing after clicking a row. The rows were buttons, so clicking one
    took focus and the input stopped receiving key events. They are divs now.
  • Commands stopped loading entirely. The session picker retried in an unbounded
    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.
  • Dropdowns outlived the click that should dismiss them, and survived Escape and
    closing the panel. Both now dismiss them.

/models

It 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/providers returns
providers as an array of { id, models: { <model-id>: … } }, and both levels
had 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 in coucou, 3 in coucou-hook, 1 ignored,
    green both with the app running and with it closed. cargo check is warning-free.
  • npx tsc --noEmit clean, npm run build clean.
  • npm run pack — the NSIS installer builds, installs silently per-user, and the
    installed coucou.exe launches and brings opencode serve up on port 47821.
  • The ignored test performs a real turn against a live model, so it is excluded to keep
    the suite free of network and credential requirements. Run it with
    cargo test -p coucou --lib -- --ignored.
  • git diff --check clean.

Not verified here, and worth saying plainly:

  • The Linux job has not been run against this branch. cargo check --workspace --all-targets and npm run pack for Linux need a Linux runner, and WSL is not
    installed 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 is
    several fixes and two merges back from this head.
  • The Swift tests in build.yml (screen geometry, link filter, chat parsing, plan
    gauge) 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

  • Upstream's animation pause is narrowed rather than used as written. It only applies
    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 island's page box is worked around, not fixed. The CSS says so at the point of
    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.
  • The secondary display is the first non-primary one in OS order, not a specific
    chosen display. On a three-display machine the picker does not say which is which.
  • Possible interference with dragging a Chromium-based window on the display the
    island is on. Reported against 7ee64b8: after dragging the island by its header, a
    Chrome 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.

igorroc and others added 13 commits September 30, 2026 19:04
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.
@imanoldiaz
imanoldiaz marked this pull request as draft October 1, 2026 09:56
freebuff-web Bot pushed a commit to Abelion512/coucou that referenced this pull request Oct 1, 2026
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>
ZidaneLima pushed a commit to ZidaneLima/coucou that referenced this pull request Oct 1, 2026
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.
AlbertMcAvoy pushed a commit to AlbertMcAvoy/coucou that referenced this pull request Oct 1, 2026
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.
@imanoldiaz imanoldiaz changed the title Windows: opencode server integration, island controls, and session picker Windows: enhanced opencode integration, island revamp, and additional features Oct 2, 2026
@imanoldiaz
imanoldiaz marked this pull request as ready for review October 2, 2026 02:18
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.
@imanoldiaz
imanoldiaz force-pushed the feat/opencode-server-integration branch from 137e921 to 05d92ad Compare October 2, 2026 03:03
AlbertMcAvoy pushed a commit to AlbertMcAvoy/coucou that referenced this pull request Oct 2, 2026
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.
@arreina

arreina commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Heads-up from a Linux build check (cargo check --workspace --all-targets on Linux Mint 22.3, with this PR merged onto current main, head 1f5a0d0): it doesn't compile on Linux. Since the Linux port, the Tauri app builds for Linux too, and some Windows-only code here isn't behind #[cfg(windows)]:

  • island.rs:407-410: win.hwnd(), WindowFromPoint, POINT, GetAncestor, GA_ROOT / GA_ROOTOWNER (Win32).
  • island.rs:504: STRIP_W / STRIP_H are not in scope there any more.
  • opencode_chat.rs:471-480, opencode_server.rs:139-212: std::os::windows::process::CommandExt and creation_flags. On main this goes through platform::no_console(&mut cmd), which is a no-op on Linux.
  • opencode.rs:125, opencode_server.rs:39: direct uses of the windows crate.

The existing platform/ module (windows.rs / linux.rs) is where these usually go, so the rest of the app stays shared.

@imanoldiaz

imanoldiaz commented Oct 3, 2026 •

Copy link
Copy Markdown
Author

Heads-up from a Linux build check (cargo check --workspace --all-targets on Linux Mint 22.3, with this PR merged onto current main, head 1f5a0d0): it doesn't compile on Linux. Since the Linux port, the Tauri app builds for Linux too, and some Windows-only code here isn't behind #[cfg(windows)]:

  • island.rs:407-410: win.hwnd(), WindowFromPoint, POINT, GetAncestor, GA_ROOT / GA_ROOTOWNER (Win32).
  • island.rs:504: STRIP_W / STRIP_H are not in scope there any more.
  • opencode_chat.rs:471-480, opencode_server.rs:139-212: std::os::windows::process::CommandExt and creation_flags. On main this goes through platform::no_console(&mut cmd), which is a no-op on Linux.
  • opencode.rs:125, opencode_server.rs:39: direct uses of the windows crate.

The existing platform/ module (windows.rs / linux.rs) is where these usually go, so the rest of the app stays shared.

@arreina Thanks — accurate report, and platform/ is the right shape for the fix.

Context: this branch doesn't author any Linux changes. The merge from current main brings upstream's Linux work in (platform/linux.rs, tauri.linux.conf.json, hook/src/unix.rs and the rest all appear in that merge), and their content matches main exactly — no local divergence, and no cfg gates removed or narrowed. The break is new files that don't compile there: opencode.rs, opencode_chat.rs and opencode_server.rs don't exist on main and predate the module.

Both island.rs findings trace to work this PR extends rather than replaces — the own_popup check is the #31 outside-click handling, and STRIP_W/STRIP_H are gone because the #28 wake strip was replaced by a real 80×24 island (HIDDEN_W/HIDDEN_H). Both are written up in the PR description; your build looks like it was against an earlier revision.

The adaptation would be ~30 lines across four files, touching no Linux file: the three creation_flags sites become platform::no_console calls (same flag on Windows, no-op elsewhere), GetLocalTime becomes platform::local_time() (already cross-platform), and cfg(windows) gates go on island.rs's own_popup call site and the TCP owner table.

I'm unsure whether it belongs in this PR — it's already large and deliberately Windows-scoped, so how the platform boundary is drawn is @Louis-CFM's call. And I can't verify a Linux build from here: no Linux system, and cargo check --target x86_64-unknown-linux-gnu fails on libdbus-sys before reaching this code.

@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.
@arreina

arreina commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Thanks for the detailed reply! One correction that matters for the adaptation: the STRIP_W/STRIP_H error isn't from an earlier revision. I checked 1f5a0d0 merged onto current main. On main, refresh_click_through (island.rs:304, added by #111 in 7ce5480, after this branch) uses STRIP_W/STRIP_H for the collapsed input region on Linux. This PR replaces those constants with HIDDEN_W/HIDDEN_H, so after the merge that line needs the new ones, or whatever size the collapsed window ends up being.

Since you can't build on Linux, I'm happy to run a Linux check (cargo check plus a quick run on X11) on whatever you push. Just ping me.

@imanoldiaz
imanoldiaz force-pushed the feat/opencode-server-integration branch 2 times, most recently from 95aed55 to c44b9d4 Compare October 3, 2026 13:44
@arreina

arreina commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Ran the Linux check on the current head (c44b9d4) merged onto main (daa4bec): cargo check --workspace --all-targets still fails with the same 17 errors, while main alone builds. Current locations:

  • island.rs:407-410: hwnd, POINT, GA_ROOT, GA_ROOTOWNER (Win32 in own_popup)
  • island.rs:504: STRIP_W / STRIP_H
  • opencode_chat.rs:471,480 and opencode_server.rs:139,143,188,212: std::os::windows / creation_flags
  • opencode.rs:125, opencode_server.rs:39: windows crate

Happy to re-run it whenever you or @Louis-CFM push the platform adaptation.

@imanoldiaz

Copy link
Copy Markdown
Author

Thanks for the detailed reply! One correction that matters for the adaptation: the STRIP_W/STRIP_H error isn't from an earlier revision. I checked 1f5a0d0 merged onto current main. On main, refresh_click_through (island.rs:304, added by #111 in 7ce5480, after this branch) uses STRIP_W/STRIP_H for the collapsed input region on Linux. This PR replaces those constants with HIDDEN_W/HIDDEN_H, so after the merge that line needs the new ones, or whatever size the collapsed window ends up being.

Since you can't build on Linux, I'm happy to run a Linux check (cargo check plus a quick run on X11) on whatever you push. Just ping me.

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/.
@imanoldiaz imanoldiaz changed the title Windows: enhanced opencode integration, island revamp, and additional features Windows: enhanced opencode integration (sessions, persistent server), island revamp (drag, compact, hide), small corrections and bug fixes Oct 4, 2026
@imanoldiaz

imanoldiaz commented Oct 4, 2026 •

Copy link
Copy Markdown
Author

@arreina Could you re-run the check against d92c1ba? The head you looked at (c44b9d4) is several fixes and two merges back, and the Linux job here is waiting at action_required.

All 17 errors came from the opencode integration being written Windows-only. Each is now behind a gate or routed through platform: the cursor poll's popup check (#[cfg(windows)], nothing exempt elsewhere), the loopback TCP table and taskkill (#[cfg(windows)] with empty-list and no-op stubs — so on Linux a server already listening isn't discovered, and one is started on demand instead), the three CommandExt/CREATE_NO_WINDOW sites (platform::no_console, a no-op), GetLocalTime (platform::local_time()), and three USERPROFILE reads (platform::home_dir(), so HOME). No Linux implementation was added, and the two merges touched no file under windows/.

Three changes are shared rather than Windows-only, in case they're worth a look:

  1. Your eb1241f rule is narrowed here to #island[data-hidden] *, which is our change to your commit rather than anything you proposed — pausing animations inside a visible island visibly breaks Mochi on WebView2, and animation: none does the same, so it isn't the pausing itself. Your measurement should still hold in the state you took it in: while the island is retracted, everything inside it is paused. Happy to put the original rule back if you read the WebView2 failure differently.
  2. html, body shrink to their content instead of claiming the window, and #root's position: fixed; inset: 0 is gone. A workaround for the page box fighting the island's box, labelled as one in the CSS — not Windows-specific in the code, so tell me if it misbehaves on WebKitGTK.
  3. dt is now clamped at both ends. ensureRunning stamps lastFrame with performance.now() while the loop resumes on an older requestAnimationFrame timestamp, so the first frame after a resume had a negative dt and ran every smoother backwards. That's why Mochi's gaze only misbehaved in the reduced bar, where the loop parks and restarts most often.

@arreina

arreina commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Re-ran it on d92c1ba merged onto current main (59f63df, no conflicts): cargo check --workspace --all-targets builds with no errors and no warnings, cargo test --workspace passes, and tsc --noEmit and vite build are clean. So the Linux side compiles now.

On the three shared changes:

  1. Narrowing to #island[data-hidden] * is fine with me. The measurement behind eb1241f was taken with the island retracted, which this still covers. The trade-off is that while the island is up, animations in views that are not on run again, so the drop frame's dashes repaint while the island is open. That's a much smaller window than before eb1241f, and if WebView2 breaks with the wider rule, this is the right call.
  2. I haven't checked the html, body change on WebKitGTK yet, since that needs the app running on a desktop. I'll report back after a run on X11.
  3. The dt clamp looks right to me: with lastFrame stamped from performance.now() and the loop resuming on an older requestAnimationFrame timestamp, a negative dt is possible, and clamping to [0, 0.05] handles it on every platform.

imanoldiaz added a commit to imanoldiaz/coucou that referenced this pull request Oct 4, 2026
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.
imanoldiaz added a commit to imanoldiaz/coucou that referenced this pull request Oct 4, 2026
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.
AlbertMcAvoy pushed a commit to AlbertMcAvoy/coucou that referenced this pull request Oct 5, 2026
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants