Repository navigation
Conversation
j/k and the arrow keys moved the cursor index but never touched threadFlick.contentY, so past the visible rows the selection walked off-screen in the thread list, the search hits and the new-message picker. The list is a multi-section Column (pinned grid, headers, three Repeaters), so ListView.positionViewAtIndex is not available. This is the shape Omarchy's audio and Tailscale panels have, and the fix is their helper: each row registers itself as root.cursorRow when its hasCursor turns true, and the three move functions call scrollCursorIntoView(), which clamps contentY so the row sits inside the viewport with a Style.space(6) margin. Synchronous on purpose — a cursor move does not change the model, so the row is already laid out. Rows now decide "am I the cursor?" by comparing modelData.chat against a root.cursorChat string instead of calling threadIndex(), which scanned every thread per row per keypress; threadIndex() had no other callers and is gone. The app window had no list navigation at all: only the bar panel's PanelKeyCatcher drove moveCursor. BlipWindow's navCatcher now forwards Up/Down/Enter through a new view.catchNavKey(), which declines while any editor has focus, so the compose, search and new-message fields keep their own arrow handling (the window's "normal editor behavior" rule).
moveCursor, moveSearchCursor and moveNewCursor wrapped with a modulo, so Down on the last of 300 threads landed on the first. Now that the list scrolls to follow the cursor, that press reads as a jump to the top, not a loop. Clamp to [0, length-1] instead, as Omarchy's Dropdown does. The wrap arrived with the a98f59e refactor and was never called out as a decision.
With the cursor stopping at the ends, Up on the first thread did nothing. It now calls startSearch(), which scrolls the list to the top (the field sits above the rows — pressed deep in the list, `/` and `n` used to focus a field that was out of view) and focuses it. Down in an empty search or new-message field goes back to the list with the cursor on the first row: the list is showing its top, so that is where the eye is. Esc keeps the old cursor and brings its row back into view — out of a field and out of a conversation alike; back() still parks a mouse user at the top. Same round trip as Omarchy's SearchableDropdown. The rows are rebuilt when the mode flips, so the scroll back is deferred a turn — the same reason Omarchy's Bluetooth panel defers positionViewAtIndex. The search hits and the new-message picker get the matching half: their field already holds focus while the arrows walk the hits, so Up from the first hit just scrolls the field back into view.
Four source-level checks in the existing "QML safety invariants" style: every move function clamps and ends in scrollCursorIntoView() (a modulo would bring the wrap back); rows register as cursorRow and threadIndex() stays gone; startSearch/startNew scroll to the top while exitSearch, exitNew and back() restore the cursor row; the window's catchNavKey yields to a focused editor before touching the arrows. A shared qmlFunction() helper slices a root-level function body up to its own closing brace, so the checks do not depend on the order functions appear in the file.
`omarchy-shell shell toggle nixfred.blip` and the stock SUPER+CTRL+<n> panel binds did nothing; the shell logged "summon: no live bar widget for: nixfred.blip". Bar.findPanelWidget accepts a bar widget as a panel only when its item has open(), close() and an `opened` property, and BarWidget had the two functions but not the property. One readonly property — the same line Omarchy's clock and weather widgets carry. README gets the stock o.bind for it next to the SUPER+M snippet and a line in the IPC list; ui.test.ts pins the property. On a multi-monitor bar the panel lives on the first screen's copy (`leader`); a follower's open() is a no-op, as before.
The ⇱ button said "Open the app window (SUPER+M)" and the security-code toast said "Super+Shift+V types it". Neither key exists unless the user has added the optional o.bind from the README, so on a stock install both promise a chord that does nothing. Omarchy's own widgets (53 tooltips) name no keys anywhere; the README keeps the bindings.
Double-click and the ⇱ button each closed the popout before calling showApp(); IPC `app` — what the README's SUPER+M bind calls — did not, so SUPER+CTRL+M then SUPER+M left the panel and the window up together. The window is the same BlipView, larger, and both mark read while focused, so two copies side by side is never the intent. The close moves into showApp() itself and the two callers drop theirs. ui.test.ts pins the order inside showApp() instead of in BlipView.
Inside a conversation the compose field holds focus, and the arrows only moved its caret; reading older messages meant the mouse wheel. Up/Down in an EMPTY compose field now select a bubble — Up from nothing takes the newest, then older; Down past the newest drops the selection and re-sticks the view to the bottom so new messages follow again. The selected row is kept in view and drawn with the theme's hover-cursor fill (Style.hoverFillFor, what Omarchy's own rows use): one Rectangle beside `content` in the Flickable, positioned from the row's y/height, so no delegate carries a background. Esc drops the selection first (back to the bottom) and leaves the thread second, the two-step Esc a text selection in a bubble already gets. A reload renumbers the rows, so onBubblesChanged clears it. With text typed the arrows keep moving the caret. PgUp/PgDn page the conversation; Home/End (from an empty field) take the oldest and newest bubble. The wheel handler and every key path share scrollConversation(), the one place that clamps contentY and maintains the bottom-stick — the flag that gates the deferred push reload. The selection is an index into `bubbles` and the target for keyboard actions (copy, open attachment, quote-reply); it adds no state to the model. README rows, CHANGELOG, two invariant tests.
With a bubble selected and the compose field empty (and no file queued — that Enter is a send and stays one): Enter opens the bubble's first attachment, else its link card, else the first URL in its text, through the same openAttachment()/openLink() a click reaches; Ctrl+C copies its text through copyText(); Ctrl+R quotes it into the compose field as "> …" and leaves the selection. The quote is plain text on purpose: iMessage's inline reply is not reachable through the bridge — no message GUID leaves the Mac and Messages' AppleScript dictionary has no reply-to. README row, CHANGELOG, one invariant test.
copyBubble(): the bubble's text wins; a bubble that is only a picture
copies its first image attachment as an image. That rides the existing
fetch queue — the same Mac round trip a click makes — whose boolean
"open when done" becomes an action string ("", "open", "copy"), and a
"copy" job ends in `wl-copy --type <mime> < <file>` with the path and type
passed as arguments, never interpolated into the shell script.
The note under the compose box was visible only while it had text, so "copied" (and "sending…", and the attach confirmation) pushed the compose box and the bubbles down for a second and a half and let them back up. It now renders a single space when empty and keeps one caption line of height. Colour follows meaning: progress and confirmations dim, failures red — before, everything but "sending…" was red, "copied" included.
The arrows' empty-field rule left no way to walk the bubbles once a reply was half-typed. Three answers, all keeping the caret's arrows intact: Edge rule — Up leaves the draft for the bubbles from its first line, Down from its last line while a bubble is selected. Omarchy's own lists (menu, clipboard, emojis) get this for free from single-line fields; the compose box is multi-line, so the caret rectangle decides. With a one-line reply, the usual case, the first Up goes straight to the bubbles. PgUp/PgDn — they move no caret, so they always work: PgUp selects the topmost wholly visible bubble and, when the selection is already there, pages so that row lands at the bottom edge (a screen with one row of overlap); PgDn mirrors it, and past the newest leaves like Down. Only a WHOLLY visible row counts as the edge — a sliver of the neighbour above otherwise turned the next PgUp into a single step; a partly visible row is the fallback for a picture taller than the view. On a Mac keyboard this is Fn+Up/Down. Shift+PgUp/PgDn — one bubble at a time from anywhere in a draft, for the reader who does not want to move the caret first. edgeVisibleBubble() scans the Repeater's items (at most 80); the reveal that keeps the selected row in view is shared as revealBubbleCursor(). Home/End keep their empty-field rule. The actions still require an empty field.
Up from the first row hands focus to the search field and keeps the cursor on row 0 so Esc and Down return there — but the row went on drawing the cursor fill under a focused text field, and a click in the field showed the same. The two thread-row colour bindings now also require cursorShown (!searchField.activeFocus); the cursor value is untouched.
A pin can live on a retired group row, and Messages merges a phone SMS thread with an email iMessage thread under one conversation. Matching only the live row dropped the pin and split the 1:1. Cluster those rows, match pins against every id, and keep alias messages in the thread view. Co-authored-by: Cursor <cursoragent@cursor.com>
Typing in a thread then switching to another lost whatever you had written: openThread() hard-cleared the compose field. Now the text is kept per chat id and restored when you return, and the panel and the app window share the same drafts. It stays in memory only — never written to disk — so it honours the standing "message text never lands on disk" invariant; a draft survives a thread or surface switch within the session but not a shell restart. - BarWidget.draftCache: chat id -> unsent text, one map for both BlipViews under the host (the demo host mirrors it). - composeField.onTextChanged writes every edit under the open chat. A send clears the field and with it the draft; leaving a thread nulls active before clearing, so the draft stays. Loading a draft fires the same handler and writes the same text back, which is harmless. - openThread() loads the chat's draft instead of blanking, caret at end. Three ui.test.ts invariants, one asserting drafts never touch disk; the CLAUDE.md "no message content on disk" invariant names drafts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…irst cursor move The pinned tile's avatar is a direct child of a ColumnLayout but sized itself with a width: binding. A layout sizes its children from their Layout hints, and with none set it took the item's width at first measurement: 56, the clamp floor, because the tiles are built before the grid has its width. Every relayout then forced 56 back; the binding's real value, about 63 at the panel's width, only stuck when a relayout followed a re-evaluation, which in practice was the first arrow press on the list. Measured on the running panel: grid width 19 -> 320, avatar 56 -> 62.8 -> 56, grid height 166 until the first cursor move, then 180. Bind Layout.preferredWidth/Height instead. Same formula, same clamp; layout and binding agree from the first frame (62.8 and grid height 180 before any key is pressed, measured the same way). One ui.test.ts invariant; CHANGELOG under Unreleased. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eyboard and every link Enter on a bubble whose text is a link sent it straight to the browser. A keypress that opens an arbitrary URL with no chance to see where it goes is the wrong default for a messaging client; the share sheet already exists for exactly this — host, full URL, then Open / Copy / Send as a deliberate choice — so Enter goes there instead. A picture still opens directly (it is local). The sheet was mouse-only, so it learns the keyboard: 1/2/3 pick, Up/Down move the highlight, Enter takes it, Esc closes; hovering moves the same highlight, so mouse and keys agree. Other keys fall through to the compose field, so a sheet over a draft never blocks typing. A sheet that opened by itself — a link you sent, a link that arrived while Blip was open, IPC — behaves the same, the sheet being the warning, except for a 700 ms grace after it appears, when Enter and digits still belong to the draft: a link landing in the same instant as the Enter meant to send must not be opened by it. A message with several links offers them all. openBubble() hands the sheet every link of the message, in order, found with the same pattern and trailing-punctuation rule linkify() uses for the anchors the mouse clicks (www. gets https://), plus Apple's card URL when the text lacks it. The same list arrives after a send (allUrls) and from the collector (IncomingLink.urls beside url; readers of url are untouched), through BarWidget and shareLink(), which now takes one URL or a list. ←/→ and ‹ › step through them, the header counts "1 of 2", host / URL / QR follow, and the QR box keeps its place while the next code is made. Found on the way: the sheet's TapHandlers took only a passive grab, so a click on the scrim, the card or a button also reached whatever lay under it — a thread row, a text selection, a link in a bubble. gesturePolicy ReleaseWithinBounds takes an exclusive grab on press; the tap now ends in the sheet. The compose field's Esc closes the sheet before it drops a bubble selection or leaves the thread (the audit's shareUrl check, on this branch too, so the rebase merges cleanly). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…number The bridge treated two Contacts cards in ONE source sharing a number as ambiguity and named nobody. That is right when the names differ (a married name kept beside the old one), but "Mom ❤️" and "Mom❤️" are a duplicate of the same card, and a heavily used conversation in the list was a bare number with no photo. name_for now compares names within a source through _name_key (NFKC, casefold, whitespace stripped) before calling a collision ambiguous; the first spelling wins. _avatar_candidates follows the same rule (CLAUDE.md: names and photos follow the one rule): every duplicate is a photo candidate, oldest first, so the first with a picture wins. Messages' "Filter Unknown Senders" files a stranger's SMS under a chat id with "(filtered)" appended (`any;-;+1818…(filtered)`). isGroupChat() keeps treating that shape as not-a-DM — nothing sends to it — but the list label is now the number (prettyHandle), not the raw id. `imsg attachment` on a row Messages in iCloud has not downloaded to the Mac (filename NULL, transfer_state 0) said "no such visible attachment", which sent people hunting for a Blip bug; it now says the attachment is not on the Mac yet. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A send was ssh + osascript on the Mac, a fixed 1.5 s wait for Messages to
write the row, then a thread reload — "sending…" under an unchanged compose
field for about three seconds. Now send() draws the bubble at once
(pending: true, captioned "Sending…"), clears the field, and queues text
sends so a second message need not wait.
The in-flight sends live in pendingSends (BarWidget memory only) and ride
every thread reload on stdin (--pending-stdin; message text never on argv).
withPendingSends() in thread.ts keeps each bubble until a real from-me row
with the same text lands — one row resolves one send, a row older than the
send (minus clock skew) does not — so an early reload cannot make it blink.
Post-send reloads fire after 600 ms, retry quietly a few times, and no
longer flash "loading…". A failed send drops its bubble, restores the text
to the field unless something newer was typed, and says why. The read
watermark (--seen) skips pending bubbles: their ts is this machine's clock.
New invariant in CLAUDE.md ("Sends are optimistic"); ui.test.ts contracts
updated to the new send shape; twelve tests for the reconciliation rules.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tiles sit in a ColumnLayout, which sizes children from Layout hints; a width: binding lost to the layout's first measurement, so they rendered small until the next relayout (the first cursor move). preferredWidth/Height fixes it at the first frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…luster A re-keyed group keeps its pin on a retired chat row's group_id, and Messages folds a phone SMS row with an email iMessage row under one group_id while keeping two chat_identifiers. Matching only the live row dropped the pin from Favorites and split the 1:1 in two. pin_order_for now sees every id in the cluster, _group_cluster_map folds merged DMs, and selectThread keeps alias DM rows the way it already keeps group alias rows. Mac side changed: imsg must be redeployed to ~/.blip/bin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
Text typed but not sent is kept per chat in BarWidget.draftCache and restored on open, so switching threads no longer loses it; the panel and the app window share one map. Memory only — back() and resetToList() null `active` before clearing the field, so leaving a thread keeps the draft while a send, which clears with `active` set, drops it. Conflicts: CHANGELOG.md and ui.test.ts, both append-at-the-same-spot with nixfred#42. Kept both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…ches the window j/k and the arrows walked the selection out of the viewport in the thread list, the search hits and the new-message picker. Rows now register themselves as the cursor row (one string compare per row instead of an O(n) threadIndex scan) and every move scrolls it back into view, with the helper Omarchy's own multi-section panels use. The cursor clamps at the ends instead of wrapping, Up from the top hands focus to the search field, Down in an empty field hands it back, and the app window gets Up/Down/Enter while no editor has focus. Conflict: CHANGELOG.md, append-at-the-same-spot. Kept both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
Two fixes found against a real Mac. Contacts: two cards in ONE source sharing a number were treated as ambiguity, so "Mom ❤️" and "Mom❤️" — one card saved twice — cost a conversation its name and photo. _name_key (NFKC, casefold, whitespace-stripped) decides sameness within a source before a collision counts as ambiguous, and _avatar_candidates follows the same rule. Two genuinely different names still resolve to nobody. A "(filtered)" stranger's chat now shows its number, and an attachment Messages in iCloud has not pulled to the Mac says so instead of "no such attachment". Send: the bubble is drawn as Enter is pressed and the Mac round trip happens behind it. In-flight sends live in memory and ride every thread reload on stdin (--pending-stdin, never argv); withPendingSends() keeps each bubble until a real from-me row with the same text lands, one row per send, and never resolves against a row older than the send minus clock skew. A failure drops the bubble and puts the text back. New invariant in CLAUDE.md. Merges cleanly with nixfred#41's drafts: send clears the field with `active` set, so the draft drops with the send. Note: CI never ran on this branch (no checks reported). Verified locally — 380 bun tests, every Mac-side python suite, py_compile on imsg. Conflict: CHANGELOG.md, append-at-the-same-spot. Kept both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
Bar.findPanelWidget (Bar.qml:509) skips any widget whose `opened` is undefined, so `omarchy-shell shell toggle nixfred.blip` and every SUPER+CTRL+<n> logged "summon: no live bar widget" and did nothing. One readonly property, the line Omarchy's own clock and weather widgets carry. Verified against the installed shell source, not the PR description. showApp() now closes the popout itself, so double-click, the ⇱ button and IPC `app` agree. The ⇱ tooltip and the security-code toast no longer name SUPER+M or Super+Shift+V: both are bindings the README asks you to add yourself. Conflict: main added `at: Date.now()` to the code toast while this shortened its body — kept both. CHANGELOG.md and ui.test.ts were append-at-the-same-spot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
A bubble cursor inside a thread: Up/Down select a bubble (from an empty compose field, or from the first/last line of a draft), Enter opens its attachment, link card or first URL, Ctrl+C copies its text — or the picture itself for a picture-only bubble — and Ctrl+R quotes it. PgUp/PgDn select the topmost/bottommost visible bubble and page from there, Shift+PgUp/PgDn step one at a time, Home/End reach the ends. Esc drops the selection before it leaves the thread. scrollConversation() becomes the single writer of the Flickable's contentY, so the bottom-stick that gates the deferred push reload cannot drift. The status line keeps its height, and only failures are red. Integration, resolved here rather than bounced back (branch base was 390df88, six merges behind): - showShareUrl() gets back the Astra A#8 QR-process kill the branch predates. It matters more with this PR than without: stepping between links re-runs qrencode, which is exactly the race that published one sheet's code under another's outFile. - The post-send block keeps nixfred#43's optimistic-send structure and takes only nixfred#39's multi-URL share call; the field-clearing lines nixfred#39 still carried were superseded by nixfred#43, which clears at send time. - nixfred#43's fetch-failure note read root.fetchJobOpen, which this PR replaces with the fetchJobAction string. Left alone it would have evaluated undefined and the note would have silently stopped firing; it now tests fetchJobAction !== "" ("" = auto-pull stays quiet, "open"/"copy" = the user asked). - The Astra A#7 test pinned the old one-line Esc handler; updated to the three-way handler, share sheet still closing before back(). 387 bun tests, every Mac-side python suite, qmlformat parse on all four QML files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
A test wrote saveState() with an undefined directory, producing a literal `undefined/` directory holding a state file, and 3c4e65d committed it. It has shipped in the repo ever since. No current test recreates it (checked by running the suite after deleting), so this is the artifact of a bug already fixed, not a live one. Deliberately NOT adding it to .gitignore: if a future test regains an undefined path, `git status` showing the directory is the symptom that finds it. Ignoring it would hide exactly the bug this file is evidence of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
IPC `goto ""` opened a nameless thread with no header, no name and nothing to send to — the panel sat on a blank conversation until you pressed Esc. A script with an unset variable is exactly how you get there, and `goto` is in the README. show() now tests the id against the same shape guard the toast's argv path already uses and returns false instead; the handler answers "not a conversation id" rather than claiming "shown". An id that merely LOOKS like a handle but is unknown still opens, which is deliberate — that is how you start a conversation with a number you have never messaged. The share sheet outlived the conversation it belonged to. It opens by itself on an ARRIVING link, and neither openThread(), back() nor resetToList() closed it, so it floated over the next conversation offering a QR for a link no longer on screen — and since the host runs resetToList() on every open, a sheet left up when the panel closed came back over the thread list. All three now dismiss it. Esc still closes the sheet before it unwinds the view (Astra A#7). Verified live on the running shell, not just in tests: goto with empty, blank, path-shaped, unicode and 5000-character input all answer "not a conversation id" while a real group id and an unknown-but-plausible number both still open; the sheet is gone after navigating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7 LR-T: ipc-goto-guard, share-sheet-lifetime, live-driving, blank-thread LR-D: blip, qml, ipc, ui LR-K: drive-it-dont-read-it, an-id-that-is-not-one, sheet-belongs-to-the-link
The shots in docs/img were rendered at 438f5c0, before nixfred#39 and nixfred#42 landed, so the README showed a share sheet with only "Esc closes" (it now offers "1-3 or ↑↓ Enter" for a message carrying several links) and pinned avatars at a size the layout no longer produces. Made the usual way: scripts/demo/blip-shots, shipping QML against the fake bridge, invented people in the 555-0100 range. No real conversation is in these. Worth knowing, because it is visible in share-sheet.png: nixfred#42 shrank the pinned avatars in the app window's narrow list column, from roughly the full cell width to 56px. That is the sizing formula finally taking effect rather than a regression — a `width:` binding inside a ColumnLayout never applied, so the tiles had been rendering at the cell width instead of the intended 0.62 of it. Whether 0.62 is the ratio you want at that column width is a look question, and yours. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7 LR-T: demo-harness, screenshots, pinned-avatar-size, docs-drift LR-D: blip, docs, ui LR-K: docs-show-a-ui-that-exists, layout-hints-beat-width-bindings
ensureWindow() defers the real `visible = true` to a Qt.callLater, because a freshly created BlipWindow has to run its window.json restore pass first. The handler toggled and then read root.windowVisible, which had not settled yet, so the show path returned "window hidden" — and the hide path, being synchronous, returned "window hidden" too. The string was therefore constant, and a script polling it to decide whether the app was up got the wrong answer every time. toggleWindow() now returns what it DID rather than what a property says a moment later. Found by driving the live IPC surface: `window` twice in a row answered "window hidden" both times while `status` reported the window focused. Verified after the fix across three toggles — every return value now matches the `status` that follows it. LR-T: ipc-window-toggle, stale-property-read, callLater, live-driving LR-D: blip, qml, ipc LR-K: report-what-you-did-not-what-a-property-says, quickshell-never-remaps
…ople Fred reported a conversation titled 3734fc1a806e4557bbb8e8967... instead of the people in it. nixfred#55 added exactly that fallback yesterday, and it was unreachable. `imsg chats` substitutes the chat identifier when a group has no display name, so `name` comes back as the raw hex id rather than empty. Both naming sites in mergeChats read `groupInfo?.name || c.name || <fallback>`, and a truthy id won the chain every time - the participant fallback could never run for precisely the groups it exists for. Pre-existing, not a regression: before nixfred#55 the same chain ended in `|| c.id`, so these groups have always shown an id. nixfred#55 added the fallback behind the value that shadows it. namedGroup() treats a name that IS the chat id, or any of its aliases, as no name at all. A re-keyed group's retired id is just as much not-a-name. Both sites go through it, and the new-chat site now gets a real handle-to-name map built from every participant the window already resolved, instead of the `new Map()` it was passed - without that it could only ever have produced bare phone numbers. Verified against the live bridge, not just tests: the reported group now reads "Craig Chapman & Keith Coughlin", and across the whole list 26 hex-id groups now show a name with zero still showing a raw id. The existing nixfred#55 test passed because it used name:null. The real Mac sends the id. The added case covers that shape through both merge paths, through a re-keyed alias, and asserts a genuine title still wins. 475 bun tests, every Mac-side suite, qmlformat and a clean tsc. LR-T: group-names, mergeChats, namedGroup, imsg-chats, pr-55-followup LR-D: blip, collector, groups LR-K: a-name-that-is-the-id-is-not-a-name, a-fallback-behind-a-truthy-value-is-dead-code Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
… store Messages files stickers — Memoji, Genmoji, sticker packs — under ~/Library/Messages/StickerCache/, not Attachments/. The path guard in `imsg attachment` knew Attachments and ~/.blip/sent only, so every sticker anyone sent came back as "attachment path escapes the Messages store": a true statement about the guard, a false one about the file. On one Mac, 50 of 2413 attachment rows live there. The guard admits StickerCache as a third root. Nothing else about it changes: the path is resolved before the check, a sibling that shares the name prefix does not match (commonpath), the size ceiling, the O_NOFOLLOW open and the S_ISREG check on the descriptor all still apply. The three-root check moves into _in_message_store() so a test can reach it; the group-photo guard keeps its own Attachments-only check, since a group photo never lives elsewhere. Test: a file under each root is admitted; ~/Library/Messages/chat.db, a StickerCache-evil sibling, a symlink planted in StickerCache that points outside, and StickerCache/../chat.db are refused. Verified on the Mac that had the failure: the sticker streams (26 KB JPEG), an attachment row pointing into /var/folders is still refused.
The test preload pointed XDG_CACHE_HOME at a scratch dir but never XDG_CONFIG_HOME, and contact-search.ts reads `country_code=` through it, falling back to ~/.config. So the suite read whatever bridge.conf the developer actually runs Blip with. Proved it here rather than taking the description on trust: with `country_code=47` in a scratch XDG_CONFIG_HOME, main fails 5 tier2 cases - `(404) 555-0123` normalises to `+474045550123` - and with this PR merged it fails none. Fred's own conf has no country_code, which is exactly why nobody on this machine ever saw it. The failure mode is the wrong way round for a test suite: CI has no bridge.conf at all, so CI stays green while the suite breaks for any contributor who has actually set Blip up, in a country other than this one. Green where nobody uses it, red where they do. 476 bun tests, Mac-side suites and qmlformat green. LR-T: pr-64, test-isolation, xdg-config-home, country-code, tier2 LR-D: blip, tests, contacts LR-K: green-in-ci-because-ci-is-missing-the-config Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…ers every ssh key The README asked for Full Disk Access on /usr/libexec/sshd-keygen-wrapper without saying what that actually grants. TCC keys the grant to the binary, not to a key, so from that point on every ssh key that can open a shell on that account can read chat.db - not just Blip's confined one. That is worth stating at the moment the reader is being asked to do it, rather than leaving them to infer it. Blip's own key still runs only the bridge tools, and the note points at docs/SECURITY.md for pinning other keys or closing port 22 a layer down, which is the mitigation nixfred#46 added. Documentation only. 476 bun tests green. LR-T: pr-65, readme, full-disk-access, tcc, security-docs LR-D: blip, docs, security LR-K: the-grant-is-per-binary-not-per-key Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
The checker nixfred#51 landed asked Hunspell for en_US and nothing else, so a draft written in any other language came back underlined word by word - the feature actively got in the way of anyone not writing English. `spell=en_US,nb_NO` checks against several dictionaries at once and `spell=off` turns the underlines off entirely. Names follow the dictionary files that `hunspell -D` lists, and a name that is not installed is skipped rather than failing the run. The default is unchanged, so nobody who was happy notices anything. Right shape for this project: another bridge.conf key, parsed not sourced, rather than a settings surface. blip-setup's list of hand-set keys that must survive a re-run gains it too. 477 bun tests, Mac-side suites and qmlformat green. LR-T: pr-63, spellcheck, hunspell, bridge-conf, dictionaries, i18n LR-D: blip, composer, config LR-K: a-checker-that-only-knows-english-underlines-every-other-language Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
The thread list, pinned tiles, search hits and new-message hits each highlighted with their own hard-coded Qt.rgba(foreground, 0.08), while the conversation's bubble cursor already used Omarchy's hover-cursor colour and alpha. Four copies of a constant that the theme is supposed to own. They now all read root.hoverFill, which is Style.hoverFillFor(foreground, accent), and the duplicate local binding on the bubble band goes away. Checked after merging: zero hard-coded 0.08 fills remain in BlipView. Default themes render identically; a theme that sets its own hover-cursor values now actually gets them in the list, which was the point. This is the follow-up nixfred#45 flagged when it landed the bubble cursor and noted the list rows still hard-coded the value. Conflict: CHANGELOG.md, this entry and nixfred#63's landing at the same spot. Kept both. 476 bun tests, Mac-side suites and qmlformat green. LR-T: pr-66, hover-fill, theme, omarchy-style, pr-45-followup LR-D: blip, qml, theme LR-K: four-copies-of-a-constant-the-theme-should-own Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…light themes Timestamps, previews and labels dimmed with Qt.darker(foreground, 1.45). Darker is only dimmer when the foreground is light on a dark background; on a light theme it drives the colour AWAY from the background, so secondary text came out heavier than the messages it sits under. Measured on Catppuccin Latte: 10:1 for the timestamps against 7:1 for the message text. Now Qt.alpha(foreground, 0.66), which moves toward the background on any theme, and matches what Omarchy's own placeholder text does. Dark themes render the same; the composer placeholder stops carrying its own separate Qt.darker constant too. Conflict: CHANGELOG.md again, third entry landing in the same spot. Kept both. The test pins it with a negative assertion - no Qt.darker on the foreground anywhere - which is a strong invariant but worth knowing about if a future change ever wants darker for something legitimately. 477 bun tests, Mac-side suites and qmlformat green. LR-T: pr-67, theme, light-theme, contrast, secondary-text, qt-alpha LR-D: blip, qml, theme LR-K: darker-is-not-dimmer-on-a-light-theme Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…Messages store Messages files stickers - Memoji, Genmoji, sticker packs - under ~/Library/Messages/StickerCache/ rather than Attachments/, and the path guard knew only the latter. Every sticker anyone sent came back as "attachment path escapes the Messages store": true about the guard, false about the file, which sent the reader hunting for a path problem that was not there. On the reporter's Mac that is 50 of 2413 attachment rows, so roughly one picture in fifty never showed. STICKER_ROOT joins ATTACHMENTS_ROOT and SENT_ROOT, and the three-root test moves into _in_message_store(path) so it can be tested directly. I verified the guard independently rather than trusting the description, because this widens what the bridge will read off disk. Built a fixture with all four shapes: a file under StickerCache is admitted, while a "StickerCache-evil" sibling that merely shares the name prefix, a symlink inside StickerCache pointing outside, and a StickerCache/../chat.db escape are all refused. realpath still runs before the check, the test is commonpath and not startswith, and the size ceiling, the O_NOFOLLOW open and the S_ISREG check on the descriptor all still run after it. The group-photo guard keeps its own Attachments-only check, which is correct - a group photo never lives elsewhere. Conflict: CHANGELOG.md, fourth entry into the same spot. Kept both. 477 bun tests, all nine Mac-side suites including this PR's five new guard cases, and qmlformat green. LR-T: pr-68, stickers, attachment-path-guard, stickercache, path-traversal LR-D: blip, bridge, mac, security LR-K: true-about-the-guard-false-about-the-file Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
… built WHAT: A 5,400-word contributor file crediting all 16 people who shaped Blip: the 11 who merged the 51 PRs (+12,262/-1,173) and the 5 who filed issues that changed the code. Each gets a section describing the specific technical work, not a thank-you line. WHY: "Thanks contributors" is a cop-out. These people found bugs a maintainer cannot find from inside his own repo: SQLite sorting NULL last on DESC so an empty group chat row overwrote the live one and broke 27 conversations (@tolewis, 17 lines); a Family Sharing child's address book outvoting the owner's own contact card two-to-one (@adamgamble); a last-ten-digit phone key that is a whole national number in North America and nothing anywhere else, leaving 99 of 386 Norwegian numbers nameless (@Fileri); EXIF orientation that sips, Qt and imv each ignore differently (@adamgamble); glob()[0] picking a dead Contacts source so setup reported a failure whose suggested fix did nothing (@jethrojones). OSINT: X handles matched ONLY from what each person published themselves — GitHub's twitter_username field, or a link on their own website — then verified as live accounts with matching real names via the `x` tool. 7 of 16 have one. Nothing guessed; the 9 without are named as such with an invitation to send one. Attaching a wrong handle in a public praise doc credits a stranger. VERIFIED: PR count, author count and line totals recomputed from `gh pr list` against the doc before commit (caught an off-by-one: 51 merged, not 50). CONTEXT: Fred asked for a war room on this, modelled on the Infomarchy 1.4.0 release post. He is an infrastructure architect, not a coder, and wanted that said plainly at the end along with credit to @dhh for Omarchy. ENABLES: git log --grep='CONTRIBUTORS' finds who built what and why they are credited; the file is the source for the X release post. TAGS: blip, contributors, credit, osint, x-handles, community, release-post Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
WHAT: A 22,831-character plain-text cut of CONTRIBUTORS.md for posting to X. All 16 contributors keep a section; the original 31,836-char file stays untouched as the repo's contributor record. WHY: The full file was 6,014 characters over Fred's paste limit. Cut 9,005 characters by dropping markdown (X renders none of it), the OSINT methodology footer, and secondary detail — while keeping every person and every number that carries the impact: 27 of 28 duplicate group rows resolving to an empty chat (tolewis), 99 of 386 Norwegian numbers nameless (Fileri), 81ms to an optimistic bubble (zachwilke), 7 of 16 Contacts sources opening while the checker claimed failure (jethrojones), two sons outvoting a wife's contact card (adamgamble). THE CATCH WORTH REMEMBERING: on X an @ is a live mention, so an @ in front of a GitHub handle either tags a stranger who happens to own that name or is dead text. Only 8 of 17 handles are verified X accounts. The other 9 are now bare GitHub handles, with a closing note explaining that an @ means verified X and a bare name means GitHub. The long CONTRIBUTORS.md keeps its @ prefixes because GitHub resolves them correctly there — same text, different rendering context, opposite correct answer. ENABLES: git log --grep='X-POST' finds the release-post source and the at-mention rule. CONTEXT: Fred's war-room contributor credit for Blip, modelled on the Infomarchy 1.4.0 post. TAGS: blip, contributors, credit, x-post, at-mentions, osint, release LR-T: blip, contributors, x-post, at-mention-hazard, credit LR-D: blip, docs, community, osint LR-K: an-at-sign-on-x-tags-whoever-owns-that-name, same-text-different-context-different-answer Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
… names WHAT: Rebuilt CONTRIBUTORS-X-POST.md (24,800 chars, under the 25k limit) from the commit author on every PR rather than from GitHub display names. 19 people: 11 merged contributors, 4 whose PRs have not landed, 4 issue reporters. WHY IT WAS WRONG: Fred caught it before posting. The first version was built from `gh pr list --state merged` and treated GitHub display names as real names. Three concrete errors: - "Nova" was published as a name. nova-centauri's commits are authored Steve Barrett <stevebarrett122@gmail.com>. A real person nearly got thanked by a display handle. - jefehoser was written up as anonymous, "no name published". All five of his commits are authored Danny Cecil <github@dannycecil.net>. - Four contributors were missing entirely because their PRs were CLOSED, not merged: Jordan Walsh (#3, jq single-quoted inside a single-quoted QML string killed BlipView.qml while a stale IPC handler still reported success), Luke Morrison (nixfred#4, new chat to an Android number fails error 22 then retries iMessage forever), John Washburn (nixfred#6, bar double-click fell through to Omarchy's gesture area), Steve Barrett (nixfred#5, notification hardening). HANDLE VERIFICATION, two gates, both required: the person published the handle themselves (GitHub twitter_username, GitHub social_accounts, or their own site), AND the live X account's display name matches the commit-author name. That surfaced two handles the first pass missed because it read only one field: @lukejmorrison and @washburnello, both declared on GitHub. 10 handles tagged, 9 people named without a tag rather than tagged wrongly. NEAR MISS WORTH RECORDING: searching X for his own projects surfaced @kurtbuhler announcing FileBlade, which reads like the author. It is not Erik. GitHub: Fileri/fileblade is fork=true, parent data-goblin/fileblade, owned by Kurt of Belgium (X kurtbuhler). Erik forked it the day after. Tagging that plausible-looking signal would have credited a stranger with 6,874 followers. ENABLES: git log --grep='commit authors' finds the rule that a GitHub display name is not a person's name and the commit object is the source of truth. CONTEXT: Fred's contributor credit for Blip, modelled on the Infomarchy 1.4.0 post. He pushed back twice and was right both times. TAGS: blip, contributors, credit, x-post, osint, commit-authors, verification LR-T: blip, contributors, x-post, handle-verification, commit-authors LR-D: blip, docs, community, osint LR-K: a-display-name-is-not-a-persons-name, the-commit-object-is-the-source-of-truth, plausible-signal-tagged-a-stranger Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
…marchy/shell WHAT: The recommended Hyprland SUPER+M bind launched the Blip app with qs -p /usr/share/omarchy/shell ipc call nixfred.blip app. It now uses omarchy-shell nixfred.blip app. WHY: On a host running a dev-linked Omarchy shell (gus: quickshell -p ~/Projects/video.on.desktop.omarchy/omarchy/shell) the hardcoded path answers 'No running instances', so SUPER+M silently did nothing when no window existed. omarchy-shell finds the running shell whatever its path. CONTEXT: Fred 2026-09-13 'SUPER M is not opening the app'. gus also had no bind at all (host config, fixed outside the repo). Avatars in the same report were a stale deploy + session state; deploy of main + shell restart brought all 9 pinned photos back (verified by window capture). LR-T: blip, superm, keybind, hyprland, omarchy-shell, readme, dev-link LR-D: blip, omarchy, hyprland LR-K: superm-bind-hardcoded-shell-path, omarchy-shell-path-independent Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjLJVjERSup2CMwVehKja7
Eight ssh calls in step 4 ran a remote command without -n, so each one
inherited the script's stdin and forwarded it to the Mac. With stdin from a
pipe or a redirect they drain it, and the later `read -r -p` hits EOF; under
`set -e` the script then dies at
Press Enter to run the permission check:
with exit 1 -- AFTER the Mac install, the python3 probe and the dedicated-key
enrolment have already run. The setup is in fact complete at that point, which
is what makes the failure so confusing to read: it looks like the permission
check failed, and it never ran.
The first reachability probe on the same line already passed -n, so this is an
oversight rather than a deliberate asymmetry. None of the eight remote commands
read stdin (install.sh, blip-check and the python3 import probe were checked),
so -n costs nothing. `ssh -O exit` is left alone -- it runs no remote command.
Adds a source-level invariant test: every ssh invocation in the script that
runs a remote command carries -n, and the two prompts that would starve are
still present. With the fix reverted the test names all eight offenders.
Eleven Text elements in BlipView.qml declared no textFormat. Qt's default is Text.AutoText, which sniffs the string and renders it as rich text when it looks like markup -- so each of those is one upstream filter away from parsing HTML out of a message, and a rich-text sink will fetch a remote <img> on its own. This is not a live vulnerability and is not claimed as one. Nothing reaches those sinks with a "<" today: collector.firstUrl/allUrls and their QML mirrors all exclude <>"', and the only unfiltered entry is the IPC share path, which is gated behind automation=on and sits inside the same-user boundary docs/SECURITY.md already accepts. The point is the sink, not the filter: 45 of the 46 sinks that DO declare a format already say PlainText (the one RichText is the deliberate linkified bubble), so these eleven were the unfinished edge of a rule the file otherwise follows everywhere. Two of them render text that arrived in a message -- root.shareUrl and root.linkHost(root.shareUrl) in the share sheet -- which is what makes this worth closing by construction rather than by the upstream regexes staying correct forever. Adds a UI invariant test that walks every .qml file, finds each Text/TextEdit block and asserts it declares its own textFormat, so a new sink cannot pick up AutoText silently. With the eleven reverted the test names all eleven.
The code-copy Process passed the digits to `sh -c 'printf %s "$VAR" | wl-copy'` through Process.environment. That was already a deliberate step away from argv, but it trades one /proc exposure for a longer-lived one: wl-copy does not exit after a copy, it stays resident to serve the selection, so the variable remains readable in /proc/<wl-copy>/environ for as long as the clipboard holds the code. Measured on Omarchy with a dummy value: after the copy, wl-copy is still alive and its environ still carries the value. That outlives both things the surrounding code is careful about -- the toast that offered the code, and the five-minute window copyCode() enforces before it refuses an older toast's code. noteCode() already keeps the digits out of the notification body precisely so they are not persisted; this is the same concern one process along. The mode is owner-only (0400), so this is the same-user boundary docs/SECURITY.md already accepts, not a new hole. It is the LIFETIME that is wrong: a value meant to live five minutes in memory sat in a long-running process's environment instead. wl-copy is now exec'd directly and the code is written to its stdin, which is closed immediately after -- the stdinEnabled/write/close pattern this codebase already uses for qrProc, threadProc, previewProc and the contact-details worker. It also drops the shell hop. Verified byte-identical on the clipboard, with no trailing newline either way, and no wl-copy process carrying the value. Adds a test asserting the copy Process declares no environment, runs wl-copy directly, and closes stdin after one write.
The README covers install and update but never removal, so the only way to find what Blip put on disk was to read blip-setup. That matters more than usual here: the install touches ~/bin, two ssh files, three XDG directories and the Mac's authorized_keys, and one of the caches holds real media. Every path is taken from the installer rather than from memory -- four shims, not three (imsg-read is easy to miss), the .pre-blip.<epoch> backups that blip-setup leaves when it displaces an existing tool, and the ~/.cache/blip subdirectories as they actually exist (att, avatars, linkpreview). Says plainly what is NOT removed and why: the ControlMaster block in ~/.ssh/config is only appended when the host had none and is harmless to keep, and the Mac's TCC grants belong to sshd-keygen-wrapper rather than to Blip, so revoking them affects everything else reached over ssh. Those are steps a user should take deliberately, not as a side effect of uninstalling. Verified against this install on both machines: every documented path exists as described, all four shims are blip-shim, and on the Mac exactly one authorized_keys line of eight matches blip-dispatch -- so the grep removes that line and leaves the everyday key alone.
…gh the environment copycode handed the digits to sh -c 'printf %s "$BLIP_CODE" | wl-copy' through Process.environment. wl-copy stays resident to serve the selection, so the code sat readable in /proc/<wl-copy>/environ for as long as the clipboard held it, past the five minutes copyCode() enforces and past the toast that offered it. Owner-only (0400), so the same-user boundary docs/SECURITY.md already accepts; the LIFETIME was the bug. wl-copy is now exec'd directly and the code is written to stdin, closed after one write (the ContactDetails.qml onStarted pattern). Verified independently on gus with dummy values: the env path left BLIP_CODE in the resident wl-copy's environ; the stdin path left it in none. 478 bun tests green on the branch. CLAUDE.md invariant updated in the PR. LR-T: pr-73, security-code, wl-copy, proc-environ, clipboard, copycode LR-D: blip, barwidget, security LR-K: the-lifetime-was-the-bug Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4334ZpEBLWvazTdqvhm8J
…l back to AutoText Eleven Text elements in BlipView.qml declared no textFormat, so Qt used AutoText: any string that looks like markup renders as rich text, and a rich-text sink fetches a remote <img> on its own. Not a live hole - the URL regexes exclude <>"' and the IPC share path sits behind automation=on - but 45 of the 46 declared sinks already said PlainText. Now all do, and a UI test walks every .qml and fails on any Text/TextEdit without its own textFormat. Verified: main's BlipView.qml under the new test fails naming exactly the eleven lines; the PR version passes. Merged on top of nixfred#73 cleanly. 479 bun tests and qmlformat green. CHANGELOG entry added in the merge (the PR had none). LR-T: pr-72, textformat, plaintext, autotext, rich-text, qml-invariant LR-D: blip, qml, security LR-K: safe-by-declaration-not-by-filter Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
… nesting The nixfred#72 test stripped ONE level of nested blocks (/\{[^{}]*\}/) before looking for textFormat, so a child with its own nested block kept its properties in the parent's text: Text { Rectangle { textFormat: ...; Item { } } } passed while the Text itself declared nothing. Collect only characters at depth 1 during the brace walk instead, so no child at any depth can speak for it. Verified: a fixture of exactly that shape passes the old test and fails the new one; main's pre-nixfred#72 BlipView.qml still fails naming all eleven lines; 479 bun tests green. Test-only, no runtime change, no deploy. LR-T: pr-72-followup, textformat, ui-test, brace-depth, false-negative LR-D: blip, tests, qml LR-K: a-child-must-not-speak-for-its-parent Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
…behind ianswope's new "Removing Blip" section: plugin, the four ~/bin shims and their .pre-blip.<epoch> backups, ~/.config ~/.local/state ~/.cache blip dirs, the dedicated ssh key, and on the Mac ~/.blip plus the confined authorized_keys line (grep -v fails safe if it is the only line). The ~/.ssh/config Host block and the sshd-keygen-wrapper TCC grants stay optional. Verified every path against scripts/blip-setup, bridge/mac/install.sh, collector.ts, linkpreview.ts and a live install on gus. Added in the merge: step 6 now names Accessibility (granted when mark-read is enabled), a CHANGELOG entry, and CLAUDE.md's shape diagram lists imsg-read among the shims (the contributor flagged that omission). 479 bun tests green. LR-T: pr-74, readme, uninstall, removal, tcc, accessibility, imsg-read LR-D: blip, docs LR-K: document-what-uninstall-leaves-behind Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GShQoaqmuXnWgMLn1SzAFL
…ot hit EOF Eight ssh calls in step 4 ran a remote command without -n, inherited the wizard's stdin and forwarded it to the Mac. With stdin from a pipe or redirect they drained it, the later read -r -p hit EOF and set -e exited 1 at "Press Enter to run the permission check" - AFTER install.sh, the python3 probe and the key enrolment had succeeded, so a working setup looked failed and invited a pointless re-enrol. Same mechanism as the shim preflight invariant in CLAUDE.md (ssh -n), reaching the last place it had not been applied. Verified independently: a stub ssh that drains stdin unless given -n makes a set -e script exit 1 before its prompt, and reach it with -n. install.sh and blip-check read no stdin (grepped), so -n costs nothing; ssh -O exit runs no remote command and is correctly exempt. The new source-level test run against main's blip-setup names exactly the eight lines. Merged over nixfred#72/nixfred#73/nixfred#74 without conflict; 481 bun tests and bash -n green. Script-only, nothing to deploy to the plugin dir. CHANGELOG entry added in the merge. LR-T: pr-71, blip-setup, ssh-n, stdin, eof, set-e, read-prompt, wizard LR-D: blip, setup, bridge LR-K: ssh-eats-stdin-again Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
… entry Release audit before 2.5.0, same method as 2.4.0: every merge since v2.4.0 checked against Unreleased. Missing and now written: nixfred#73 (security code on wl-copy stdin), nixfred#50 (failed send keeps its bubble), nixfred#51 (accessible composer, spelling, arrow keys), nixfred#52 (resizable panel + compact header), nixfred#53 (Ctrl+1-9 pins, back arrow), nixfred#55 + 2197e0d (unnamed groups named after people, composite avatar), nixfred#59/nixfred#60/nixfred#61 (contact scans, read-only card view, vCard copy/save), nixfred#48 (window restores on its workspace), nixfred#36 (no uninvited consent prompt from blip-check). Attributions checked against gh PR authors: nixfred#48 is @jefehoser; nixfred#36 reported by @jacobaross. Skipped as not user-facing: nixfred#64 (test isolation), nixfred#65 (README line), CONTRIBUTORS docs. No version bump yet: blip-b2 is landing the avatar-cache fix and nixfred#62 for Fred, and 2.5.0 waits for those. LR-T: changelog, release-audit, 2.5.0, attribution, backfill LR-D: blip, docs, release LR-K: audit-the-release-not-assume-it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
…atch WHAT: The photo map moves from BlipView to BarWidget.avatarCache (file:// paths only, never bytes), the same way draftCache already lives on the host. avatar.ts gains cachedAvatar() (the disk's answer or null) and --batch: handles one per line on stdin, one JSON line out per handle, disk hits first and the Mac only for misses, and once the Mac proves unreachable the rest are answered unreachable instead of each sitting out a 20 s timeout. --retry now trusts a .none marker for AVATAR_RETRY_MS (15 min) instead of skipping it. BlipView streams the batch through a SplitParser, gathers one frame of row requests per batch with Qt.callLater, and turns anything a crashed run never answered into letters that the next open retries. WHY: Fred, 2026-09-13: "contacts photos ... seem to load each time on the desktop app SUPER-M". The window is rebuilt on every show (a Quickshell FloatingWindow never re-maps), so its map started empty and every row spawned its own bun avatar.ts, serially, ~14 ms each. Worse, --retry ignored the negative marker outright, so all 251 photoless contacts of 318 cached on gus went back to the Mac, ~171 ms of ssh each, on every open: about 45 s of pictures trickling in. Measured: all 251 markers rewritten within the hour. VERIFIED: bun test 483 pass / 0 fail, including new cases for the retry window, batch dedupe and disk-before-Mac order, and the stop-after-unreachable path. A live batch over the sidebar's handles answered in 23 ms, no ssh. ENABLES: git log --grep='avatarCache' or --grep='--batch' finds this. TAGS: blip, avatar, contact-photos, cache, super-m, window-recreated, retry, negative-marker, batch, splitparser, performance Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sending a URL popped the QR / copy / LocalSend sheet over the conversation
after every successful send, which interrupted the thread you were typing in.
The post-send openShare call is gone; right-click on any link, a selected
link's keys, and the auto sheet for an INCOMING link all still open it.
This reverses a 2.3.0 choice ("send it, then offer the QR / LocalSend / copy
for the same link"). Taken on Fred's 2026-09-13 go-ahead to merge it: a sheet
you did not ask for, over your own message, is the wrong default, and every
path that asks for the sheet explicitly is untouched.
Verified: trial merge onto current main (4bad82d) applied cleanly; bun test
484 pass / 0 fail with the merged tree. README caption updated in the PR.
LR-T: pr-54, share-sheet, send-link, openShare, localsend, qr
LR-D: blip, blipview, ux
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mouse wheel WHAT: Unreleased entry for the compose-box wheel scroll that rode along in 4bad82d (the contact-photo commit swept the working tree, so its message never mentioned it). MouseArea.onWheel on composeSlot, direct 1:1 like the conversation, NoButton so clicks and selection reach the field, and a draft that fits passes the wheel on. WHY: nixfred#62 (apexbenny, on 2.3.2) saw only five lines of a long draft. The typing-blind half shipped in 2.4.0 (ba1d8f9, composeFlick follows the caret); the lines above the caret were still reachable only by arrow keys. VERIFIED: bun test 484 pass / 0 fail with the new wheel test; deployed to gus, shell log clean, status healthy. TAGS: blip, issue-62, composer, compose-box, wheel-scroll, changelog, 2.5.0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L4334ZpEBLWvazTdqvhm8J
Sixty-plus commits since 2.4.0 from six contributors. Headline changes: contact photos from one cached batch instead of trickling in on every window open, a failed send keeps its bubble and says why, a resizable panel with a compact header, Ctrl+1-9 for pins, unnamed groups named after their people with a composite avatar, contact review with vCard copy/save, and a sweep of security fixes (security code off /proc, PlainText sinks, key pinned over Tailscale, no uninvited consent prompt). Release housekeeping: - CHANGELOG audited against every merge since v2.4.0 (0549c58 backfilled eleven missing entries); nixfred#54 was the last one without an entry, added here. - manifest, README badge and CHANGELOG heading move together to 2.5.0; ui.test.ts pins all three to each other. - docs/img NOT regenerated: the Test Drive VM's display is smaller than the demo crop and shows Omarchy's first-run hint, so those shots were rejected on pixel check. The 2.4.0 images stay; only their header version is stale. bun test and qmlformat green. LR-T: release, 2.5.0, version-bump, changelog, manifest, badge LR-D: blip, release LR-K: photos-at-once-sends-that-stay-put Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PeUtMhaSaA9aiazebBUMS
Stamps arrived as the Mac's naive wall clock ("2026-09-07 14:33:12") and
were compared against the Linux clock — the same string only while both
machines sat in one timezone. A Mac an hour ahead put every read mark ahead
of every message, so nothing ever counted as unread; an hour behind and the
backlog re-toasted. And once a year, in the DST fall-back hour, the Mac's
own clock repeats: two messages an hour apart carried the SAME stamp, so
ordering, the watermark and "newer than the mark" stopped meaning anything
for that hour. Every `a.ts < b.ts` in the collector was already assuming
lexical order equals chronological order; only UTC actually provides it.
imsg's JSON now emits ISO-8601 UTC (fmt_ts); the plain-text renders keep the
Mac's clock (fmt_ts_local), because a person reading `imsg recent` wants the
time they remember. On the Linux side localNowTs() became nowTs() in the same
format, and every label converts to the READER's zone at render: localDay()
and formatStamp() in thread.ts, stampMs()/localDay()/fmtTime() in BlipView.
Day dividers no longer slice the date out of the string — that is UTC's day,
and the divider belongs at your midnight.
Upgrading is covered from both ends, and both halves are load-bearing
together: loadState() migrates the marks a pre-UTC release wrote, and stamps
are normalised at the two fetch doors so a Mac still on the old bridge keeps
working. Migrating the marks alone would have sorted every message below
every mark (" " < "T") and silently emptied the badge and the toasts.
The suite runs pinned to TZ=UTC, where all of this is invisible; the new
timezone.test.ts and bridge/mac/test_wire_time.py set their own zones and
are what actually cover it. Verified live against the gateway Mac on both
the old and the new bridge: 298 threads, unread unchanged, no toast storm
on migration, and 23:47:54Z rendering as 6:47 PM in CDT.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xxS9WCT7ifHhbDjYyGnHS
Co-authored-by: zachwilke <zachwilke@users.noreply.github.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8da602a716
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Labels are rendered in LOCAL time from UTC wire stamps, so an unpinned TZ | ||
| // would make every clock/day assertion depend on where the test ran. Pin it; | ||
| // the tests that are ABOUT the conversion set their own zone explicitly. | ||
| process.env.TZ ||= "UTC"; |
There was a problem hiding this comment.
Override an existing TZ when pinning the suite
When the test runner already exports a non-UTC timezone, ||= preserves it instead of performing the promised pin. I verified that TZ=America/New_York bun test produces 28 failures in timestamp formatting, decoration, and pending-send tests, so developers and CI environments with TZ set cannot satisfy the required green suite; assign UTC unconditionally and let the dedicated timezone tests temporarily override it.
AGENTS.md reference: AGENTS.md:L17-L19
Useful? React with 👍 / 👎.
| function stampMs(ts) { | ||
| var t = String(ts || "") | ||
| if (t === "") return NaN | ||
| return Date.parse(t.indexOf("T") >= 0 ? t : t.replace(" ", "T")) |
There was a problem hiding this comment.
Move timestamp parsing out of QML
The optimistic-send path now implements timestamp parsing and local-day/grouping decisions in QML while the tests exercise only the separate thread.ts implementation. This creates two sources of truth for the same pending-bubble behavior, so a parser or timezone fix can make the instant bubble disagree with the bubble rendered after reload; put this logic in a tested TypeScript/module helper and leave QML to consume its result.
AGENTS.md reference: AGENTS.md:L17-L19
Useful? React with 👍 / 👎.
Split out of nixfred#47 (first of five, as requested). Rebased onto current
main(2.5.0).What
Stamps cross the bridge as ISO-8601 UTC (
2026-09-07T18:33:12Z). Local time is a display concern: bubbles, day dividers, "Today"/"Yesterday" and read receipts convert to the reader's zone at render, so a day still breaks at your midnight.Why
Stamps arrived as the Mac's naive wall clock and were compared against the Linux clock — the same string only while both machines sat in one timezone. A Mac an hour ahead put every read mark ahead of every message (nothing unread); an hour behind and the backlog re-toasted. In the DST fall-back hour the Mac's own clock repeats, so two messages an hour apart carried the same stamp and lexical order stopped being chronological order. Every
a.ts < b.tsin the collector already assumed that property; only UTC actually provides it.imsg's JSON emitsfmt_ts(UTC). Plain-text CLI renders keepfmt_ts_local(the time a person readingimsg recentremembers).Upgrading is covered from both ends, and both halves are load-bearing together:
loadState()migrates marks a pre-UTC release wrote, and stamps are normalised at the two fetch doors (fetchMessages,loadThread, plusfetchChats'last) so a Mac still on the old bridge keeps working. Migrating the marks alone would sort every message below every mark (" "<"T") and silently empty the badge and the toasts.This closes the open ROADMAP item ("Timestamps are Mac-local wall-clock strings").
How it was verified
bun testis green (497 tests; CI-equivalent Mac-tool tests including newtest_wire_time.py)CLAUDE.md→ named below, and the doc updated if it changedinvariants touched: Every stamp inside Blip is UTC; local time is a DISPLAY concern. Marks migrate on load; stamps normalise at the fetch doors. The suite is pinned to
TZ=UTC(test-setup.ts);timezone.test.tsandbridge/mac/test_wire_time.pyset their own zones and are what actually cover the conversion.Rebase notes
mainmoved a long way since nixfred#47. Conflict resolutions kept current-main behaviour (chat-id sanitising, pin_name, failed-sendPendingSendfields,XDG_CONFIG_HOMEtest isolation) and appliedtoUtcStamponfetchChats().last. AselectIncomingLinksfixture added onmainafter nixfred#47 still used a naive stamp; it is now UTC so it does not sort below a UTC watermark (" "<"T").Supersedes the UTC slice of nixfred#47 (
688f2cb).