diff --git a/windows/README.md b/windows/README.md
index 60374024c..6235df50c 100644
--- a/windows/README.md
+++ b/windows/README.md
@@ -66,7 +66,95 @@ exits cleanly if the app is closed, slow or crashed — **a Claude Code session
never blocked or slowed down by Coucou.** If nobody answers a permission request
in time, Coucou stays quiet and Claude Code asks in the terminal as usual.
-It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash.
+It works wherever Claude Code runs — the Claude desktop app, VS Code, Windows
+Terminal, PowerShell, Git Bash. The Claude pill takes the name of the app the
+session is in, and its ↗ brings that app forward.
+
+### Watching a session
+
+
+
+A session shows on its card, as the prototype draws it: the conversation's
+title, the step under way — its icon, its name, the file or the command it is at
+— and under it a look at what the step did: the lines of the file it read, the
+diff of its edit, the command it ran with the end of what that printed. Once the
+turn is over the card says **Done** and shows the first lines of what Claude
+replied.
+
+
+
+Click the card for the **session panel**. On the left, Mochi's column: the
+session's name and its last steps, each going, done or failed. On the right, the
+session's **journal**, read like a conversation, in the order things happened:
+
+| A line of the journal | What it shows |
+|---|---|
+| What you asked | Your words, as you sent them |
+| A file read | Its first lines, numbered |
+| An edit | Its diff, the old line struck and the new ones typed under it |
+| A command | The command, then the end of what it printed |
+| A search | What it found |
+| A question from Claude | Its options, and the ones that were picked — on the island or in Claude Code |
+| A tool that had to ask first | **needs permission**, then **allowed** or **denied** |
+| The end of the turn | What Claude said, as it wrote it — also behind **Read reply** on the finished card |
+
+The journal follows the session while you are at its end, and stays where you
+scrolled otherwise. **N files**, at the top right, lists every file the session
+changed, each with its whole diff.
+
+
+
+An edit is shown the moment Claude Code reports it, so the typing is a replay of
+it, about a second behind. Nothing changes at a stroke: a new line rises into
+place, a state's colour fades to the next. The island only watches: to write to
+Claude, there is Claude Code.
+
+### Several sessions at once
+
+
+
+Two conversations in the Claude app, one more in a terminal: each is followed on
+its own, and one of them is in front — the one the card, the panel and Mochi
+show. With more than one, the session's card and the panel's head say how many
+there are: that chip opens the list of them — each with its name, its project
+and where it is at: at work, waiting for you, finished — and a click puts one in
+front. The chip takes a colour when a session behind the one on show wants
+looking at.
+
+The island stays on the session it shows for as long as that one is at work or
+being looked at. Another one takes its place when the first has nothing going
+on, or to ask for something. When two ask at once, the second waits its turn:
+the card says how many are waiting, and the next comes forward once the first is
+answered. Up to four sessions are followed; past that, the one at rest and heard
+from longest ago gives its place. Sessions started by a program rather than a
+person — a review run by a plugin, a script using the SDK — are left alone.
+
+### Answering from the island
+
+
+
+
+- **A question** Claude asks with its question tool opens on the island with
+ its options, each with what it means. Pick one — or several, then **Send** —
+ type your own with **Other…**, **Skip** it, or hand it back with **Answer in
+ Claude**. The session goes on exactly as if you had answered in Claude Code.
+- **A permission request for an edit** shows the diff the edit would make before
+ you click **Allow**.
+
+### What it reads
+
+Everything comes from the hooks Claude Code already sends — nothing is asked of
+Anthropic, and nothing leaves your machine. Of what a tool gives back, the relay
+forwards a few lines and no more: the diff of an edit, the first lines of a file
+that was read, the last lines a command printed, the names a search found, the
+answers to a question. A session's journal is kept in memory and empties as it
+fills — its last 80 lines, the oldest going for each new one — and is gone with
+the session. Two
+things are read from disk by the relay, and only then: the file an edit asks permission for (to show its diff —
+it is never written), and the end of the session's transcript at the start and
+end of a turn (for the conversation's title and Claude's last message). That
+transcript's format is Claude Code's own: if it changes, the title and the last
+message simply stop showing. None of it is written to the log.
## Chat and keys
@@ -77,6 +165,128 @@ only ask whether a key exists. Same for every integration key.
No telemetry. The only network requests Coucou makes are to the services you
configure yourself.
+## GitHub
+
+
+
+With GitHub as the focused pill, the overview shows your year at a glance and
+your latest activity. Click the figure — or the arrow — and the island opens the
+**GitHub panel**, where everything is read without leaving for the browser.
+
+
+
+| Where | What you see |
+|---|---|
+| **Activity** | The contribution graph and your latest events. Click a day of the graph for what was done that day; click a line for its sheet |
+| **Projects** | Your repositories, most recently pushed first, each with its latest build |
+| A project | Description, languages, its last builds, its latest pull request, its latest deployment |
+| A pull request | State, branches, labels, review, checks, the files with their diff |
+| An issue, a push, a release | The same, each as its own sheet: the discussion, the commits and their files, the notes and assets |
+| A run | Its jobs as mini Mochis, each with its steps and how long they took — followed live while it runs |
+| Comments | A pull request's conversation: the description, the reviews, and each thread on the lines it is about |
+| A file | Its diff, syntax-coloured. A thread opens the file on the line it was written on |
+
+
+
+
+
+The trail on the left is the way back: every step you went through stays there,
+one click away. The arrow at the top right opens the same thing on github.com.
+
+### News
+
+
+
+Three things make Mochi speak up, with a sound and a card: **a build of yours
+breaks**, **a pull request of yours is merged**, and **somebody opens a pull
+request on one of your projects** — the ones the Projects tab lists. **Open**
+goes straight to the run or the pull request. Each project of that tab has a
+bell: click it to mute a project you do not want to hear from, and again to give
+it its voice back. **OK** folds the island and leaves the news on the pill
+for five minutes, so it isn't lost: click Mochi, or the card, to go to it.
+
+### The token
+
+
+
+**Settings… → Integrations → GitHub**: switching it on opens its setup under its
+line. Create a [fine-grained token](https://github.com/settings/personal-access-tokens/new)
+with **Repository access: All repositories** and these permissions, all
+**Read-only**:
+
+| Permission | What it is for |
+|---|---|
+| Actions | Builds, runs, jobs and steps |
+| Contents | Commits, diffs and releases |
+| Deployments | A project's latest deployment |
+| Issues | Issues and their comments |
+| Pull requests | Pull requests, reviews and threads |
+| Events (account, optional) | Your activity in private repositories |
+
+Metadata is added by GitHub on its own. **Test connection** checks each of them
+and names the one that is missing; in the panel, a part the token cannot read
+says which permission it needs instead of showing up empty.
+
+Coucou only reads, and nothing else is ever asked of GitHub. The token is stored
+in the Windows Credential Manager like every other key: it never touches the
+disk or the log, and the interface never sees it — every request is made by the
+Rust side.
+
+### How often it asks
+
+- Every **2 minutes** for the activity, the projects and their builds. Answers
+ GitHub says are unchanged (`304`) cost nothing against the rate limit.
+- Every **20 seconds**, builds only, and only while one of your builds is running.
+- A sheet is fetched **when you open it**, and kept for a minute.
+- Nothing while Coucou is paused or GitHub is switched off.
+
+## Spotify
+
+
+
+A pill that needs no key and no account. Switch it on in **Settings… →
+Integrations**, like the others — four pills at most show next to Mochi.
+
+Its card shows the cover of what plays, the song, who plays it and the album it
+is from, with previous, play/pause and next. A title too long for its line runs
+across it. The cover stands where Mochi does on the other cards: he steps out
+of this one.
+
+
+
+With Spotify's pill in front, the folded island shows the cover too, in Mochi's
+place, and unfolding carries it to the card.
+
+
+
+When the song changes, the island unfolds on that card for a few seconds and
+folds back, under a light that says why it opened: a green glow behind the
+cover, and a sheen across it. No sound — there is music playing. It never opens
+over something that waits for you, nor while the island is already open, and a
+mouse on the island keeps it there. **Settings… → Integrations → Spotify** has a
+switch to keep it folded.
+
+### Where it comes from
+
+Windows already knows what every player plays — it is what the volume flyout
+shows — and says when it changes. The pill asks Windows: the song, the artist,
+the album, the cover, whether it plays or is paused. Nothing is asked of
+Spotify and nothing touches the network; the keys go to Spotify's own session,
+never to another player.
+
+That is also where it stops:
+
+- Only the Spotify desktop app is seen. Spotify in a browser tab is the
+ browser's.
+- Until something has been played since the app was opened, Windows has nothing
+ to say about it, and neither has the card.
+- Windows is told one artist and the album. Featured artists show only when
+ the song's title names them, and the playlist a song is played from is not
+ something a player tells the system.
+
+The idea of a keyless Spotify pill comes from corefusiion's pull request on the
+main repository (Louis-CFM/coucou#80).
+
## Build it yourself
You need [Rust](https://rustup.rs), [Node 20+](https://nodejs.org), and the
@@ -91,9 +301,17 @@ npm run pack # builds the installer and drops it in windows/release/
```
`npm run dev` alone serves the front end in an ordinary browser, which is enough
-to work on the island's looks. It also serves `dev/upload-preview.html`, which
-replays the whole file-drop choreography on a loop — the one part of the UI that
-otherwise needs a real drag from Explorer to see. Neither page ships in the app.
+to work on the island's looks. It also serves two pages that never ship in the
+app:
+
+- `dev/upload-preview.html` replays the whole file-drop choreography on a loop —
+ the one part of the UI that otherwise needs a real drag from Explorer to see.
+- `dev/github-preview.html` shows the GitHub card and panel on made-up data, so
+ they can be worked on without a token. The links at the bottom of the page go
+ to each screen: a project, a run in progress, a failing build, the comments.
+- `dev/claude-preview.html` plays a made-up Claude Code session through the
+ island's own hook handler: a file being written, a question, a permission
+ request with its diff, the end of a turn, several sessions at once.
`npm run pack` leaves two files in `windows/release/`, the same names the release
workflow publishes:
@@ -142,10 +360,15 @@ problems. It stays on your machine.
the top edge instead of hiding in a notch.
- Permission approval works from **any** terminal; the Mac build only listens to
VS Code sessions.
+- The [session panel](#watching-a-session), answering Claude's questions and the
+ diff on a permission request are Windows-only for now.
- Not in this version: sending a file by email, dragging Mochi onto a window to
attach it as context, and jumping to a specific terminal window — "Open
terminal" opens the working folder in VS Code when `code` is on your `PATH`.
- Cal.com shows the next bookings as a list rather than the Mac's calendar.
+- The [Spotify](#spotify) pill is Windows-only for now.
+- GitHub goes further than the Mac's card: the [panel](#github), the news of a
+ broken build or a merged pull request, and comments are Windows-only for now.
## Linux
diff --git a/windows/dev/claude-preview.html b/windows/dev/claude-preview.html
new file mode 100644
index 000000000..c0b99b351
--- /dev/null
+++ b/windows/dev/claude-preview.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
diff --git a/windows/dev/github-preview.ts b/windows/dev/github-preview.ts
new file mode 100644
index 000000000..d19e2cbe3
--- /dev/null
+++ b/windows/dev/github-preview.ts
@@ -0,0 +1,427 @@
+// Dev harness: the real island with made-up GitHub data, in a plain browser, so
+// the card and the panel can be looked at without a token or the Rust side.
+// Not part of the app bundle. `npm run dev`, then /dev/github-preview.html.
+
+import "../src/style.css";
+import {
+ Bridge, type GithubActivity, type GithubBuild, type GithubCommentsDetail, type GithubData, type GithubDay,
+ type GithubDetail, type GithubFile, type GithubJob, type GithubProject, type GithubPullDetail, type GithubRemark,
+ type GithubRepo, type GithubRunDetail, type GithubStep, type GithubTarget,
+} from "../src/core/bridge";
+import { State } from "../src/core/state";
+import { Island } from "../src/island/island";
+
+const params = new URLSearchParams(location.search);
+const minutesAgo = (m: number) => new Date(Date.now() - m * 60_000).toISOString();
+
+const pullTarget = (repo: string, number: number): GithubTarget => ({ kind: "pull", repo, number });
+const pushTarget = (repo: string, count: number, branch: string): GithubTarget => ({
+ kind: "commits", repo, head: "a1b2c3d4e5f6", count, branch, author: null, from: null, to: null,
+});
+
+const activity: GithubActivity[] = [
+ { kind: "pr_merged", repo: "mochi/coucou", title: "GitHub panel for the Windows island", detail: "#12", url: "https://github.com", at: minutesAgo(4), target: pullTarget("mochi/coucou", 12) },
+ { kind: "push", repo: "mochi/coucou", title: "Keep the last snapshot through an error", detail: "3 commits · windows-github-panel", url: "https://github.com", at: minutesAgo(38), target: pushTarget("mochi/coucou", 3, "windows-github-panel") },
+ { kind: "pr_opened", repo: "mochi/coucou", title: "Windows: GitHub panel", detail: "#12", url: "https://github.com", at: minutesAgo(95), target: pullTarget("mochi/coucou", 12) },
+ { kind: "issue_opened", repo: "louis-cfm/coucou", title: "Defender flags the installer", detail: "#9", url: "https://github.com", at: minutesAgo(60 * 5), target: { kind: "issue", repo: "louis-cfm/coucou", number: 9 } },
+ { kind: "release", repo: "mochi/tour-convention-geneve", title: "Sprint 3", detail: "v0.3.0", url: "https://github.com", at: minutesAgo(60 * 26), target: { kind: "release", repo: "mochi/tour-convention-geneve", tag: "v0.3.0" } },
+ { kind: "issue_closed", repo: "mochi/tour-convention-geneve", title: "Map tiles flicker on zoom", detail: "#41", url: "https://github.com", at: minutesAgo(60 * 50), target: { kind: "issue", repo: "mochi/tour-convention-geneve", number: 41 } },
+ { kind: "pr_closed", repo: "mochi/dotfiles", title: "Try another prompt theme", detail: "#3", url: "https://github.com", at: minutesAgo(60 * 72), target: pullTarget("mochi/dotfiles", 3) },
+ { kind: "create", repo: "mochi/sandbox", title: "Created the repository", detail: null, url: "https://github.com", at: minutesAgo(60 * 24 * 6), target: { kind: "project", repo: "mochi/sandbox" } },
+ { kind: "push", repo: "mochi/sandbox", title: "Pushed", detail: "main", url: "https://github.com", at: minutesAgo(60 * 24 * 6), target: pushTarget("mochi/sandbox", 1, "main") },
+];
+
+// The sheets behind those lines, one per kind, with a real-looking diff.
+const PATCH = [
+ "@@ -12,9 +12,14 @@ export function enterGithubPanel() {",
+ " sweepNext = true;",
+ "- if (sheet) closeSheet();",
+ "- else touchSheet();",
+ "+ if (day) stopSearching(day);",
+ "+ day = null;",
+ "+ clearStack();",
+ "+ touch();",
+ " const d = githubData();",
+ " if (!d || Date.now() - d.fetchedAt > STALE_MS) void Bridge.refreshIntegration(ID);",
+ " }",
+ "@@ -40,6 +45,8 @@ function eventRow(",
+ " const style = ACTIVITY_STYLE[item.kind];",
+ "+ // A line of activity opens its sheet; GitHub is the last resort.",
+ "+ const open = () => openTarget(item.target, item.title, item.url);",
+ " return h(",
+ "\\ No newline at end of file",
+].join("\n");
+const files: GithubFile[] = [
+ { path: "windows/src/views/github.ts", status: "modified", additions: 612, deletions: 58, patch: PATCH, truncated: true },
+ { path: "windows/src-tauri/src/github_detail.rs", status: "added", additions: 540, deletions: 0, patch: PATCH, truncated: false },
+ { path: "windows/src/style.css", status: "modified", additions: 180, deletions: 6, patch: PATCH, truncated: false },
+ { path: "windows/screenshots/panel.png", status: "added", additions: 0, deletions: 0, patch: null, truncated: false },
+];
+const ci = (state: "success" | "failure" | "running" | "neutral"): GithubBuild => ({
+ id: 1, state, workflow: "CI", branch: "windows-github-panel", url: "https://github.com", at: minutesAgo(6),
+});
+// A run with its jobs side by side; `running` catches it halfway.
+const live = params.has("running");
+const runStart = Date.now() - (live ? 100 : 7 * 60) * 1000;
+const t = (s: number) => new Date(runStart + s * 1000).toISOString();
+const step = (name: string, from: number, to: number | null, outcome = "passed"): GithubStep => ({
+ name, outcome,
+ state: outcome === "failed" ? "failure" : outcome === "skipped" ? "neutral" : to == null ? "running" : "success",
+ startedAt: outcome === "skipped" ? null : t(from), endedAt: to == null ? null : t(to),
+});
+const jobs: GithubJob[] = [
+ {
+ id: 1, name: "lint", state: "success", outcome: "passed", url: "https://github.com", runner: "ubuntu-latest",
+ startedAt: t(4), endedAt: t(46),
+ steps: [step("Set up job", 4, 6), step("Checkout", 6, 8), step("npm ci", 8, 31), step("Type-check", 31, 45), step("Complete job", 45, 46)],
+ },
+ {
+ id: 2, name: "build (windows)", state: live ? "running" : "success", outcome: live ? "running" : "passed",
+ url: "https://github.com", runner: "windows-latest", startedAt: t(5), endedAt: live ? null : t(212),
+ steps: [
+ step("Set up job", 5, 9), step("Checkout", 9, 12), step("Set up Rust", 12, 41), step("Restore cache", 41, 52),
+ live ? step("cargo build --release", 52, null) : step("cargo build --release", 52, 198),
+ ...(live ? [] : [step("Save cache", 198, 210), step("Complete job", 210, 212)]),
+ ],
+ },
+ {
+ id: 3, name: "test", state: live ? "running" : "failure", outcome: live ? "running" : "failed",
+ url: "https://github.com", runner: "ubuntu-latest", startedAt: t(5), endedAt: live ? null : t(141),
+ steps: [
+ step("Set up job", 5, 7), step("Checkout", 7, 9), step("Set up Rust", 9, 35),
+ live ? step("cargo test", 35, null) : step("cargo test", 35, 139, "failed"),
+ ...(live ? [] : [step("Upload report", 139, 139, "skipped"), step("Complete job", 139, 141)]),
+ ],
+ },
+ {
+ id: 4, name: "deploy", state: live ? "running" : "neutral", outcome: live ? "queued" : "skipped",
+ url: "https://github.com", runner: null, startedAt: null, endedAt: null, steps: [],
+ },
+];
+const runDetail: GithubRunDetail = {
+ kind: "run", repo: "mochi/coucou", id: 1, workflow: "CI", title: "Keep the last snapshot through an error",
+ branch: "windows-github-panel", event: "push", actor: "mochi", attempt: 1, url: "https://github.com",
+ state: live ? "running" : "failure", outcome: live ? "running" : "failed",
+ startedAt: t(0), endedAt: live ? null : t(212), jobs, moreJobs: 0,
+};
+
+// `running=play`: a run that goes on while you watch, over some fifty seconds
+// from the moment the page opened — lint passes, then test (or breaks, with
+// `fail`), then build, then deploy wakes up and ships. Every answer is the
+// run as it would stand at that moment, so the panel's own refresh sees it
+// move: what its jobs' Mochis and the big one do can be watched to the end.
+const played = params.get("running") === "play";
+const opened = Date.now();
+
+function playedRun(): GithubRunDetail {
+ const elapsed = (Date.now() - opened) / 1000;
+ const at = (s: number) => new Date(opened + s * 1000).toISOString();
+ const breaks = params.has("fail");
+ // from / to: seconds from the opening; a step's number is where in its job it ends.
+ const plans: { id: number; name: string; runner: string; from: number; to: number; ends: "passed" | "failed" | "skipped"; steps: [string, number][] }[] = [
+ { id: 1, name: "lint", runner: "ubuntu-latest", from: -16, to: 14, ends: "passed", steps: [["Set up job", 0.1], ["Checkout", 0.2], ["npm ci", 0.7], ["Type-check", 1]] },
+ { id: 2, name: "build (windows)", runner: "windows-latest", from: -15, to: 34, ends: "passed", steps: [["Set up job", 0.1], ["Set up Rust", 0.3], ["cargo build --release", 0.95], ["Save cache", 1]] },
+ { id: 3, name: "test", runner: "ubuntu-latest", from: -15, to: 24, ends: breaks ? "failed" : "passed", steps: [["Set up job", 0.1], ["Set up Rust", 0.4], ["cargo test", 1]] },
+ { id: 4, name: "deploy", runner: "ubuntu-latest", from: 34, to: 46, ends: breaks ? "skipped" : "passed", steps: [["Set up job", 0.2], ["Upload", 1]] },
+ ];
+ const jobs: GithubJob[] = plans.map((p) => {
+ const waiting = elapsed < p.from;
+ const skipped = p.ends === "skipped" && !waiting;
+ const done = elapsed >= p.to;
+ const length = p.to - p.from;
+ let before = 0;
+ const steps: GithubStep[] = waiting || skipped ? [] : p.steps.map(([name, end], i) => {
+ const from = p.from + before * length;
+ const to = p.from + end * length;
+ before = end;
+ const broke = p.ends === "failed" && done && i === p.steps.length - 1;
+ const over = elapsed >= to;
+ return {
+ name,
+ state: broke ? "failure" : over ? "success" : "running",
+ outcome: broke ? "failed" : over ? "passed" : elapsed >= from ? "running" : "queued",
+ startedAt: elapsed >= from ? at(from) : null, endedAt: over ? at(to) : null,
+ };
+ });
+ return {
+ id: p.id, name: p.name, url: "https://github.com", runner: p.runner, steps,
+ state: skipped ? "neutral" : !done ? "running" : p.ends === "failed" ? "failure" : "success",
+ outcome: skipped ? "skipped" : waiting ? "queued" : !done ? "running" : p.ends,
+ startedAt: waiting || skipped ? null : at(p.from), endedAt: done && !skipped ? at(p.to) : null,
+ };
+ });
+ const over = jobs.every((j) => j.state !== "running");
+ const broke = jobs.some((j) => j.state === "failure");
+ return {
+ ...runDetail, jobs,
+ state: !over ? "running" : broke ? "failure" : "success",
+ outcome: !over ? "running" : broke ? "failed" : "passed",
+ startedAt: at(-16), endedAt: over ? at(breaks ? 34 : 46) : null,
+ };
+}
+
+// What was said on the pull request: a description, a thread still open on a
+// line of the first file's diff, a review asking for changes, a thread settled,
+// one on code that has changed since, an answer, an approval.
+const said = (author: string | null, minutes: number, body: string, cut = false): GithubRemark => ({
+ author, body, cut, at: minutesAgo(minutes), url: "https://github.com",
+});
+const comments: GithubCommentsDetail = {
+ kind: "comments", repo: "mochi/coucou", number: 12, title: "GitHub panel for the Windows island", url: "https://github.com",
+ earlier: params.has("earlier"),
+ entries: params.has("quiet") ? [] : [
+ { kind: "description", ...said("mochi", 60 * 50, "Turns the GitHub pill into a panel: the year's activity, each project's sheet, and the sheet behind every line.\n\nEverything is fetched on the click, by the Rust side, with a read-only token.") },
+ {
+ kind: "thread", path: "windows/src/views/github.ts", line: 16, side: "right", resolved: false, outdated: false, more: 0,
+ code: [
+ { number: 13, sign: "+", text: " if (day) stopSearching(day);" },
+ { number: 14, sign: "+", text: " day = null;" },
+ { number: 15, sign: "+", text: " clearStack();" },
+ { number: 16, sign: "+", text: " touch();" },
+ ],
+ remarks: [
+ said("louis", 60 * 30, "Is this touch() needed? clearStack() already drops the sheets."),
+ said("mochi", 60 * 29, "It is: the view only redraws when its key changes, and a sheet dropped without a new stamp stayed on screen."),
+ ],
+ },
+ { kind: "review", state: "changes requested", ...said("louis", 60 * 28, "Nearly there. Two things on the code, and the panel should not poll while the island is hidden.") },
+ {
+ kind: "thread", path: "windows/src/style.css", line: 46, side: "right", resolved: true, outdated: false, more: 2,
+ code: [{ number: 46, sign: "+", text: " // A line of activity opens its sheet; GitHub is the last resort." }],
+ remarks: [said("kirzen", 60 * 27, "Typo."), said("mochi", 60 * 26, "Fixed.")],
+ },
+ {
+ kind: "thread", path: "windows/src-tauri/src/github_detail.rs", line: null, side: "right", resolved: false, outdated: true, more: 0,
+ code: [
+ { number: 286, sign: "", text: " if !force {" },
+ { number: 287, sign: "-", text: " if let Some(cached) = CACHE.get(&key) {" },
+ { number: 287, sign: "+", text: " if let Some((at, cached)) = CACHE.lock().unwrap().get(&key) {" },
+ ],
+ remarks: [said("louis", 60 * 26, "A minute of cache is fine here, but say so where the constant is.")],
+ },
+ { kind: "comment", ...said("mochi", 60 * 20, "Both fixed, and nothing is fetched on a timer any more: the sheets load on the click only.\n\nThe run under a pull request is the one exception, and only while the panel is open.") },
+ { kind: "comment", ...said(null, 60 * 12, "A very long comment, cut short by Coucou so the panel stays a panel…", true) },
+ { kind: "review", state: "approved", ...said("louis", 9, "") },
+ ],
+};
+
+const details: Record = {
+ run: runDetail,
+ comments,
+ pull: {
+ kind: "pull", repo: "mochi/coucou", number: 12, title: "GitHub panel for the Windows island", url: "https://github.com",
+ state: "merged", author: "mochi", base: "main", head: "windows-github-panel",
+ additions: 1332, deletions: 64, changedFiles: 14, commits: 18,
+ comments: params.has("quiet") ? 0 : 3, threads: params.has("quiet") ? 0 : 3, review: "approved",
+ reviewers: [{ login: "louis", state: "approved" }, { login: "kirzen", state: "commented" }],
+ labels: [{ name: "windows", color: "#0e8a16" }, { name: "enhancement", color: "#a2eeef" }],
+ files, createdAt: minutesAgo(60 * 50), mergedAt: minutesAgo(4), mergedBy: "louis", closedAt: minutesAgo(4),
+ ci: ci(params.has("running") ? "running" : "success"), missing: [],
+ },
+ issue: {
+ kind: "issue", repo: "louis-cfm/coucou", number: 9, title: "Defender flags the installer", url: "https://github.com",
+ state: "open", author: "mochi", comments: 4, assignees: ["louis"],
+ body: "Windows Defender reports Trojan:Win32/Wacatac.H!ml on the unsigned installer. It's a machine-learning false positive; a report is under review.",
+ labels: [{ name: "windows", color: "#0e8a16" }, { name: "bug", color: "#d73a4a" }],
+ createdAt: minutesAgo(60 * 5), closedAt: null,
+ },
+ commits: {
+ kind: "commits", repo: "mochi/coucou", branch: "windows-github-panel", total: 3,
+ commits: [
+ { sha: "a1b2c3d", id: "a1b2c3d4e5f6", message: "Keep the last snapshot through an error", author: "mochi", at: minutesAgo(38), url: "https://github.com" },
+ { sha: "9f8e7d6", id: "9f8e7d6c5b4a", message: "Fade the list only while there is more", author: "mochi", at: minutesAgo(52), url: "https://github.com" },
+ { sha: "5a4b3c2", id: "5a4b3c2d1e0f", message: "Close the sheet when the panel reopens", author: "mochi", at: minutesAgo(70), url: "https://github.com" },
+ ],
+ additions: 22, deletions: 4, files: files.slice(0, 2), ci: ci("failure"), url: "https://github.com", missing: [],
+ },
+ release: {
+ kind: "release", repo: "mochi/tour-convention-geneve", tag: "v0.3.0", name: "Sprint 3", url: "https://github.com",
+ body: "What's new: the interactive map, the torch relay journal, and the partner pages.", author: "mochi",
+ publishedAt: minutesAgo(60 * 26), prerelease: false, downloads: 1284,
+ assets: [{ name: "site-build.zip", downloads: 1200, size: 18_400_000 }, { name: "checksums.txt", downloads: 84, size: 512 }],
+ },
+ project: { kind: "locked", permission: "Contents" },
+};
+Bridge.githubDetail = async (target) => {
+ await new Promise((r) => setTimeout(r, 700));
+ if (params.has("locked")) return { kind: "locked", permission: "Contents" };
+ if (played && target.kind === "run") return playedRun();
+ if (played && target.kind === "pull") {
+ return { ...(details.pull as GithubPullDetail), ci: ci(playedRun().state) };
+ }
+ return details[target.kind];
+};
+
+const repo = (
+ name: string, language: [string, string] | null, stars: number, openPrs: number,
+ build: GithubRepo["build"], pushedMinutesAgo: number, priv = false,
+): GithubRepo => ({
+ fullName: `mochi/${name}`, url: "https://github.com", private: priv,
+ language: language?.[0] ?? null, languageColor: language?.[1] ?? null,
+ stars, openPrs, pushedAt: minutesAgo(pushedMinutesAgo), build,
+});
+const run = (state: "success" | "failure" | "running" | "neutral", m: number) => ({
+ id: m, state, workflow: "CI", branch: "main", url: "https://github.com", at: minutesAgo(m),
+});
+
+const repos: GithubRepo[] = [
+ // A project you contribute to without owning it: shown with its owner.
+ { ...repo("coucou", ["Swift", "#F05138"], 1204, 3, run("success", 20), 2), fullName: "louis-cfm/coucou" },
+ repo("coucou", ["Rust", "#dea584"], 4, 2, run("running", 1), 4),
+ repo("tour-convention-geneve", ["TypeScript", "#3178c6"], 38, 5, run("failure", 50), 50),
+ repo("dotfiles", ["Shell", "#89e051"], 12, 0, run("success", 60 * 26), 60 * 26),
+ repo("notes", null, 0, 0, null, 60 * 30, true),
+ repo("sandbox", ["Python", "#3572A5"], 3, 1, run("neutral", 60 * 24 * 6), 60 * 24 * 6),
+ repo("portfolio", ["Astro", "#ff5a03"], 27, 0, run("success", 60 * 24 * 9), 60 * 24 * 9),
+];
+
+// A plausible year: quiet weekends, a few busy stretches, a streak up to today.
+// Seeded, so every reload draws the same graph.
+const today = new Date();
+const todayUtc = Date.UTC(today.getFullYear(), today.getMonth(), today.getDate());
+const firstSunday = todayUtc - (new Date(todayUtc).getUTCDay() + 52 * 7) * 86_400_000;
+const days = Math.round((todayUtc - firstSunday) / 86_400_000) + 1;
+let seed = 7;
+const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
+const counts = Array.from({ length: days }, (_, i) => {
+ const weekday = new Date(firstSunday + i * 86_400_000).getUTCDay();
+ const busy = Math.sin(i / 23) > 0.3 ? 2.2 : 1;
+ const base = weekday === 0 || weekday === 6 ? 0.25 : 1;
+ const n = Math.floor(rand() * 6 * busy * base - (rand() < 0.35 ? 3 : 0));
+ return i >= days - 6 ? Math.max(1, n) : Math.max(0, n);
+});
+const level = (n: number) => (n === 0 ? 0 : n <= 2 ? 1 : n <= 5 ? 2 : n <= 8 ? 3 : 4);
+
+const data: GithubData = {
+ login: "mochi",
+ profileUrl: "https://github.com",
+ totalStars: 1284,
+ // `pushed`: pushes GitHub gave no message for come first, as they often do.
+ activity: params.has("empty") ? [] : params.has("pushed") ? [{ ...activity[8], at: minutesAgo(14) }, { ...activity[8], at: minutesAgo(31) }, ...activity] : activity,
+ repos: params.has("empty") ? [] : repos,
+ contributions: {
+ total: counts.reduce((a, b) => a + b, 0),
+ start: new Date(firstSunday).toISOString().slice(0, 10),
+ counts,
+ levels: counts.map(level),
+ },
+ fetchedAt: Date.now() - (params.has("error") ? 12 * 60_000 : 0),
+};
+
+// A project's sheet normally comes from Rust; here every project gets this one.
+const runState = ["success", "success", "failure", "success", "success", "neutral", "success", "success"] as const;
+const sheet: GithubProject = {
+ fullName: "mochi/coucou",
+ url: "https://github.com",
+ description: "A tiny friend that lives at the top of your screen and keeps an eye on your Claude Code sessions.",
+ homepage: "https://coucou.example.com",
+ private: false,
+ createdAt: "2024-03-12T09:00:00Z",
+ stars: 4,
+ forks: 1,
+ languages: [
+ { name: "Rust", color: "#dea584", share: 0.46 },
+ { name: "TypeScript", color: "#3178c6", share: 0.38 },
+ { name: "CSS", color: "#663399", share: 0.12 },
+ { name: "Other", color: null, share: 0.04 },
+ ],
+ runs: runState.map((state, i) => ({
+ id: i, state, workflow: "CI", branch: "main",
+ title: i === 0 ? "Keep the last snapshot through an error" : "Earlier work",
+ actor: "mochi", url: "https://github.com",
+ startedAt: minutesAgo(62 + i * 90), updatedAt: minutesAgo(60 + i * 90),
+ })),
+ pull: {
+ number: 12, title: "GitHub panel for the Windows island", url: "https://github.com",
+ state: "merged", author: "mochi", additions: 1320, deletions: 94, changedFiles: 14,
+ review: "approved", comments: 3, at: minutesAgo(4),
+ },
+ deploy: {
+ environment: "Production", state: "success", url: "https://coucou.example.com",
+ creator: "vercel", sha: "a1b2c3d", at: minutesAgo(58),
+ },
+ missing: params.has("locked") ? ["Deployments"] : [],
+};
+// `slow` stands in for a real network, long enough to watch Mochi search.
+Bridge.githubProject = async () => {
+ const ms = Number(params.get("slow"));
+ if (params.has("slow")) await new Promise((r) => setTimeout(r, ms > 100 ? ms : 2500));
+ return sheet;
+};
+
+// Any clicked day gets this one, after a short wait so the loader shows.
+const aDay: GithubDay = {
+ items: [
+ { kind: "push", repo: "mochi/coucou", title: "4 commits", detail: null, url: "https://github.com", target: pushTarget("mochi/coucou", 4, "main") },
+ { kind: "push", repo: "mochi/tour-convention-geneve", title: "1 commit", detail: null, url: "https://github.com", target: pushTarget("mochi/tour-convention-geneve", 1, "main") },
+ { kind: "pr_merged", repo: "mochi/coucou", title: "GitHub panel for the Windows island", detail: "#12", url: "https://github.com", target: pullTarget("mochi/coucou", 12) },
+ { kind: "review", repo: "louis-cfm/coucou", title: "Fix the hook timeout", detail: "#31", url: "https://github.com", target: pullTarget("louis-cfm/coucou", 31) },
+ ],
+ privateCount: 2,
+};
+Bridge.githubDay = async () => {
+ await new Promise((r) => setTimeout(r, 900));
+ return aDay;
+};
+
+State.loadIntegrationTasks();
+State.integrations.integration_github = {
+ data: data as unknown as Record,
+ error: params.has("error") ? "No connection" : null,
+ loaded: true,
+ configured: true,
+};
+// `news=fail` or `news=merge`: what the panel shows when the pill's news comes
+// in while it is open.
+if (params.has("news")) {
+ State.integrations.integration_github.news = params.get("news") === "merge"
+ ? { success: true, label: "#12 merged", detail: "GitHub panel for the Windows island", open: { target: pullTarget("mochi/coucou", 12), label: "#12", url: "https://github.com", title: "#12 GitHub panel for the Windows island", facts: [{ kind: "repo", text: "coucou" }, { kind: "by", verb: "merged by", text: "louis" }, { kind: "diff", additions: 1332, deletions: 64 }, { kind: "files", text: "14 files" }] } }
+ : { success: false, label: "CI failed on coucou", detail: "main", open: { target: { kind: "run", repo: "mochi/coucou", id: 1 }, label: "CI", url: "https://github.com", title: "CI failed on coucou", facts: [{ kind: "step", text: "test \u203a cargo test" }, { kind: "branch", text: "main" }, { kind: "commit", text: "Keep the last snapshot through an error" }, { kind: "by", verb: "by", text: "mochi" }] } };
+}
+// With the news, Mochi's state as the island's handler sets it. `view=pill`
+// shows it the way it arrives with the island away: GitHub's Mochi at the
+// front, the island unfolded on the finished or the error card.
+const news = State.integrations.integration_github.news;
+const pill = params.get("view") === "pill";
+if (news) {
+ const task = State.tasks.find((t) => t.id === "integration_github");
+ if (task) {
+ task.state = news.success ? "finished" : "error";
+ task.steps = news.detail ? [news.label, news.detail] : [news.label];
+ task.stepIndex = task.steps.length - 1;
+ }
+}
+State.setFocus("integration_github");
+// Pinned, so the island doesn't fold away while it's being looked at.
+State.isPinned = true;
+
+const island = new Island(document.getElementById("root")!);
+if (pill && news) island.alert(news.success ? "finished" : "error");
+else island.alert(params.get("view") === "overview" ? "overview" : "github");
+
+// The tab is the panel's own state; get there the way a person would.
+if (params.get("tab") === "projects") {
+ requestAnimationFrame(() => {
+ document.querySelector(".gh-trail .gh-step:nth-child(2)")?.click();
+ if (params.has("sheet")) {
+ requestAnimationFrame(() => document.querySelector(".gh-list .gh-row:nth-child(2)")?.click());
+ }
+ });
+}
+
+// `pr`: straight to the project's pull request, once its sheet is up. With
+// `talk`, on to what was said on it; `talk=code`, on to its first thread's line.
+if (params.has("pr")) {
+ const steps = [
+ () => document.querySelectorAll(".gh-sheet .gh-block")[1],
+ ...(params.has("talk") ? [() => [...document.querySelectorAll(".gh-sheet button.gh-block")].find((b) => /in the conversation|No comments yet/.test(b.textContent ?? ""))] : []),
+ ...(params.get("talk") === "code" ? [() => document.querySelector(".gh-thread-head")] : []),
+ ];
+ const walk = window.setInterval(() => {
+ const next = steps[0]?.();
+ if (!next) return;
+ steps.shift();
+ next.click();
+ if (steps.length === 0) window.clearInterval(walk);
+ }, 200);
+}
diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs
index 5bb363781..75f56d5f3 100644
--- a/windows/hook/src/main.rs
+++ b/windows/hook/src/main.rs
@@ -10,9 +10,10 @@
//! * Every step runs under a deadline enforced by the main thread, so a pipe that
//! accepts the connection and then stops reading cannot wedge the session
//! either: we abandon the worker and exit.
-//! * Only `PermissionRequest` waits for an answer, because approving from the
-//! island is the whole point. No answer means empty stdout, and Claude Code
-//! asks in the terminal exactly as if Coucou were not installed.
+//! * Only `PermissionRequest` waits for a human, because approving from the
+//! island — or answering Claude's question there — is the whole point. No
+//! answer means empty stdout, and Claude Code asks in its own window exactly
+//! as if Coucou were not installed.
//!
//! Usage: `coucou-hook ` (the name is also read from the JSON).
@@ -28,8 +29,36 @@ const FIRE_AND_FORGET_BUDGET: Duration = Duration::from_secs(2);
const DECISION_BUDGET: Duration = Duration::from_secs(110);
/// Fields that are pointless to forward and can be enormous (a whole file read,
-/// a full command output). The island never shows them.
+/// a full command output). The island shows a few lines of them at most, and
+/// those are taken out first.
const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"];
+/// The tools that leave a file changed. What they did to it is the one part of
+/// a tool's response the island shows.
+const EDIT_TOOLS: &[&str] = &["Edit", "Write", "MultiEdit", "NotebookEdit"];
+/// The tools that run a command: what it printed is what they have to show.
+const COMMAND_TOOLS: &[&str] = &["Bash", "PowerShell"];
+/// Lines of a tool's result forwarded — the end of what a command printed, the
+/// start of a file that was read — and the longest of them, in characters.
+const MAX_RESULT_LINES: usize = 40;
+const MAX_RESULT_LINE: usize = 240;
+/// The tool Claude asks its questions with. Its input goes back whole, with the
+/// answers added, so it is kept as it came.
+const QUESTION_TOOL: &str = "AskUserQuestion";
+/// Longest diff forwarded for one change, and longest line in it.
+const MAX_PATCH: usize = 12_000;
+const MAX_PATCH_LINE: usize = 400;
+/// Largest file read to show what an edit would do to it.
+const MAX_PROPOSAL_FILE: u64 = 2_000_000;
+/// Unchanged lines kept on each side of a proposed change.
+const CONTEXT_LINES: usize = 3;
+/// How much of a transcript's end is read — for the conversation's title, and
+/// for what Claude said last — and the events worth reading it on: a turn's
+/// ends, not each of its tools.
+const TRANSCRIPT_TAIL: u64 = 256 * 1024;
+const TRANSCRIPT_EVENTS: &[&str] = &["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest"];
+const MAX_TITLE: usize = 80;
+/// Longest piece of Claude's last message forwarded with a Stop, in characters.
+const MAX_LAST_MESSAGE: usize = 6_000;
/// Longest string forwarded for any single field; the island truncates to far
/// less than this anyway.
const MAX_FIELD_LEN: usize = 2_000;
@@ -45,8 +74,9 @@ mod unix;
use unix::connect;
fn main() {
- let Some((payload, event)) = read_event() else { std::process::exit(0) };
+ let Some(Event { line: payload, name: event, tool_input }) = read_event() else { std::process::exit(0) };
+ // Only a permission request waits for a human, so only it gets the long budget.
let waits_for_answer = event == "PermissionRequest";
let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET };
@@ -59,8 +89,8 @@ fn main() {
let _ = tx.send(talk(&payload, waits_for_answer));
});
- if let Ok(Some(decision)) = rx.recv_timeout(budget) {
- if let Some(json) = decision_json(&decision) {
+ if let Ok(Some(answer)) = rx.recv_timeout(budget) {
+ if let Some(json) = reply_json(&answer, tool_input.as_ref()) {
let mut out = std::io::stdout();
let _ = writeln!(out, "{json}");
let _ = out.flush();
@@ -79,6 +109,9 @@ fn decision_json(decision: &str) -> Option {
// business, not Claude Code's.
"allow" | "always" => r#"{"behavior":"allow"}"#.to_string(),
"deny" => r#"{"behavior":"deny","message":"Denied from Coucou"}"#.to_string(),
+ // A question the user chose not to answer: Claude is told so, and decides
+ // what to do without the answer.
+ "skip" => r#"{"behavior":"deny","message":"The user skipped this question from Coucou, without answering it."}"#.to_string(),
_ => return None,
};
Some(format!(
@@ -86,8 +119,320 @@ fn decision_json(decision: &str) -> Option {
))
}
+/// What Coucou said about a permission request, as the JSON Claude Code
+/// expects. Besides the bare `allow` and `deny`, one line carries something:
+/// `answer {…}`, for Claude's question tool — the answers picked on the island,
+/// keyed by question.
+fn reply_json(answer: &str, tool_input: Option<&serde_json::Value>) -> Option {
+ let answer = answer.trim();
+ match answer.strip_prefix("answer ") {
+ Some(answers) => answered_json(answers, tool_input?),
+ None => decision_json(answer),
+ }
+}
+
+/// Allows the question tool with its input as Claude sent it plus `answers`:
+/// the tool then returns them as if they had been picked in Claude Code.
+fn answered_json(answers: &str, tool_input: &serde_json::Value) -> Option {
+ let answers = serde_json::from_str::(answers).ok()?;
+ if !answers.is_object() {
+ return None;
+ }
+ let mut input = tool_input.as_object()?.clone();
+ input.insert("answers".into(), answers);
+ let input = serde_json::Value::Object(input);
+ Some(format!(
+ r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{{"behavior":"allow","updatedInput":{input}}}}}}}"#
+ ))
+}
+
+/// A unified diff being written: capped in length, its lines counted whole.
+#[derive(Default)]
+struct Patch {
+ text: String,
+ additions: u64,
+ deletions: u64,
+ truncated: bool,
+}
+
+impl Patch {
+ fn hunk(&mut self, old_start: usize, old_lines: usize, new_start: usize, new_lines: usize) {
+ self.text.push_str(&format!("@@ -{old_start},{old_lines} +{new_start},{new_lines} @@\n"));
+ }
+
+ /// One line with its sign. Past the cap it is still counted, no longer kept.
+ fn push(&mut self, sign: char, line: &str) {
+ match sign {
+ '+' => self.additions += 1,
+ '-' => self.deletions += 1,
+ _ => {}
+ }
+ if self.truncated || self.text.len() + line.len() > MAX_PATCH {
+ self.truncated = true;
+ return;
+ }
+ let mut end = line.len().min(MAX_PATCH_LINE);
+ while !line.is_char_boundary(end) {
+ end -= 1;
+ }
+ self.text.push(sign);
+ self.text.push_str(&line[..end]);
+ self.text.push('\n');
+ }
+
+ fn json(self, created: bool) -> serde_json::Value {
+ serde_json::json!({
+ "patch": self.text, "additions": self.additions, "deletions": self.deletions,
+ "truncated": self.truncated, "created": created,
+ })
+ }
+}
+
+/// The unified diff of what an edit tool did, from the hunks Claude Code puts in
+/// its response — with the lines' real numbers, which the tool's input has not.
+/// A file written new has no hunk: its content is the diff.
+fn change_of(response: &serde_json::Value) -> Option {
+ let mut patch = Patch::default();
+ let hunks = response.get("structuredPatch").and_then(|v| v.as_array());
+ let created = response.get("type").and_then(|v| v.as_str()) == Some("create");
+ match hunks {
+ Some(hunks) if !hunks.is_empty() => {
+ for hunk in hunks {
+ let n = |key: &str| hunk.get(key).and_then(|v| v.as_u64()).unwrap_or(0) as usize;
+ patch.hunk(n("oldStart"), n("oldLines"), n("newStart"), n("newLines"));
+ for line in hunk.get("lines").and_then(|v| v.as_array()).into_iter().flatten() {
+ let line = line.as_str().unwrap_or_default();
+ let mut chars = line.chars();
+ patch.push(chars.next().unwrap_or(' '), chars.as_str());
+ }
+ }
+ }
+ _ if created => {
+ let content = response.get("content").and_then(|v| v.as_str())?;
+ patch.hunk(0, 0, 1, content.lines().count());
+ for line in content.lines() {
+ patch.push('+', line);
+ }
+ }
+ _ => return None,
+ }
+ Some(patch.json(created))
+}
+
+/// A few lines of what a tool gave back, for the island to show under the
+/// step: the end of what a command printed, the start of a file that was read,
+/// what a search found. Nothing else of a tool's response leaves the relay.
+fn result_of(tool: &str, response: &serde_json::Value) -> Option {
+ let text = |key: &str| response.get(key).and_then(|v| v.as_str()).unwrap_or_default();
+ let names = || {
+ let files = response.get("filenames").and_then(|v| v.as_array());
+ files.into_iter().flatten().filter_map(|v| v.as_str()).collect::>().join("\n")
+ };
+ match tool {
+ _ if COMMAND_TOOLS.contains(&tool) => {
+ let printed = [text("stdout"), text("stderr")].iter().filter(|s| !s.trim().is_empty()).cloned().collect::>().join("\n");
+ excerpt(&plain(&printed), None, true)
+ }
+ "Read" => {
+ let file = response.get("file")?;
+ let start = file.get("startLine").and_then(|v| v.as_u64()).unwrap_or(1);
+ excerpt(file.get("content")?.as_str()?, Some(start), false)
+ }
+ "Grep" if !text("content").trim().is_empty() => excerpt(text("content"), None, false),
+ "Grep" | "Glob" => excerpt(&names(), None, false),
+ _ => None,
+ }
+}
+
+/// The first lines of a text, or its last when `tail` — what a command ends
+/// on is what it has to say. `start` is the number of the first line, for a file.
+fn excerpt(text: &str, start: Option, tail: bool) -> Option {
+ let lines: Vec<&str> = text.trim_end().lines().collect();
+ if lines.iter().all(|line| line.trim().is_empty()) {
+ return None;
+ }
+ let truncated = lines.len() > MAX_RESULT_LINES;
+ let kept = if tail { &lines[lines.len().saturating_sub(MAX_RESULT_LINES)..] } else { &lines[..lines.len().min(MAX_RESULT_LINES)] };
+ let kept: Vec = kept.iter().map(|line| clip(line.trim_end(), MAX_RESULT_LINE)).collect();
+ Some(serde_json::json!({ "text": kept.join("\n"), "start": start, "truncated": truncated, "tail": tail }))
+}
+
+/// What a command printed without the escape sequences that colour it in a terminal.
+fn plain(text: &str) -> String {
+ let mut out = String::with_capacity(text.len());
+ let mut chars = text.chars().peekable();
+ while let Some(c) = chars.next() {
+ if c != '\u{1b}' {
+ out.push(c);
+ continue;
+ }
+ // ESC [ … up to the letter that ends the sequence; a lone ESC just goes.
+ if chars.peek() == Some(&'[') {
+ chars.next();
+ for c in chars.by_ref() {
+ if ('@'..='~').contains(&c) {
+ break;
+ }
+ }
+ }
+ }
+ out
+}
+
+/// The file as Claude Code reads it: text, with Unix line ends. None when it
+/// is not there, or too large to be worth showing a diff of.
+fn read_text(path: &str) -> Option {
+ let size = std::fs::metadata(path).ok()?.len();
+ if size > MAX_PROPOSAL_FILE {
+ return None;
+ }
+ let bytes = std::fs::read(path).ok()?;
+ Some(String::from_utf8_lossy(&bytes).replace("\r\n", "\n"))
+}
+
+/// One edit applied to a text, the way the Edit tool applies it.
+fn apply(text: &str, edit: &serde_json::Value) -> Option {
+ let old = edit.get("old_string")?.as_str()?;
+ let new = edit.get("new_string")?.as_str()?;
+ if old.is_empty() || !text.contains(old) {
+ return None;
+ }
+ let all = edit.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false);
+ Some(if all { text.replace(old, new) } else { text.replacen(old, new, 1) })
+}
+
+/// What changes between two texts, as one hunk: the lines they share at both
+/// ends are left out but for a few of context, and everything between is old
+/// then new. Not the shortest diff there is — the honest one a relay can afford.
+fn diff_of(before: &str, after: &str, created: bool) -> Option {
+ let a: Vec<&str> = before.lines().collect();
+ let b: Vec<&str> = after.lines().collect();
+ let mut head = 0;
+ while head < a.len() && head < b.len() && a[head] == b[head] {
+ head += 1;
+ }
+ let mut tail = 0;
+ while tail < a.len() - head && tail < b.len() - head && a[a.len() - 1 - tail] == b[b.len() - 1 - tail] {
+ tail += 1;
+ }
+ let (a_end, b_end) = (a.len() - tail, b.len() - tail);
+ if head == a_end && head == b_end {
+ return None;
+ }
+ let from = head.saturating_sub(CONTEXT_LINES);
+ let after_lines = tail.min(CONTEXT_LINES);
+ let (old_lines, new_lines) = (a_end - from + after_lines, b_end - from + after_lines);
+ let mut patch = Patch::default();
+ // A side with no line starts at 0, as `diff` writes it.
+ patch.hunk(if old_lines == 0 { 0 } else { from + 1 }, old_lines, if new_lines == 0 { 0 } else { from + 1 }, new_lines);
+ a[from..head].iter().for_each(|l| patch.push(' ', l));
+ a[head..a_end].iter().for_each(|l| patch.push('-', l));
+ b[head..b_end].iter().for_each(|l| patch.push('+', l));
+ a[a_end..a_end + after_lines].iter().for_each(|l| patch.push(' ', l));
+ Some(patch.json(created))
+}
+
+/// What an edit asking for permission would do to its file: the file as it is,
+/// the edit applied to a copy, and the diff between the two. Nothing is written.
+fn proposal_of(tool: &str, input: &serde_json::Value) -> Option {
+ let path = input.get("file_path")?.as_str()?;
+ let before = read_text(path);
+ let exists = std::path::Path::new(path).exists();
+ let after = match tool {
+ "Write" if before.is_some() || !exists => input.get("content")?.as_str()?.replace("\r\n", "\n"),
+ "Edit" => apply(before.as_deref()?, input)?,
+ "MultiEdit" => input
+ .get("edits")?
+ .as_array()?
+ .iter()
+ .try_fold(before.clone()?, |text, edit| apply(&text, edit))?,
+ _ => return None,
+ };
+ diff_of(before.as_deref().unwrap_or_default(), &after, !exists)
+}
+
+/// The conversation's title, as Claude Code last wrote it in the session's
+/// transcript. That file's format is Claude Code's own and may change: whatever
+/// goes wrong here, there is simply no title. Only the file's end is read — a
+/// long session's transcript runs to hundreds of megabytes.
+fn title_of(tail: &str) -> Option {
+ tail.lines().rev().find_map(title_in)
+}
+
+/// The end of a session's transcript, as text.
+fn tail_of(transcript: &str) -> Option {
+ use std::io::{Seek, SeekFrom};
+ let mut file = std::fs::File::open(transcript).ok()?;
+ let size = file.metadata().ok()?.len();
+ file.seek(SeekFrom::Start(size.saturating_sub(TRANSCRIPT_TAIL))).ok()?;
+ let mut tail = Vec::new();
+ file.read_to_end(&mut tail).ok()?;
+ Some(String::from_utf8_lossy(&tail).into_owned())
+}
+
+/// What Claude said last, from the transcript's end: the text of its last
+/// message that has any. Read the same forgiving way as the title.
+fn last_message_of(tail: &str) -> Option {
+ tail.lines().rev().find_map(said_in)
+}
+
+/// The text of an assistant's line of the transcript, if it says anything.
+fn said_in(line: &str) -> Option {
+ if !line.contains("\"assistant\"") {
+ return None;
+ }
+ let entry = serde_json::from_str::(line).ok()?;
+ if entry.get("type")?.as_str()? != "assistant" {
+ return None;
+ }
+ let text = entry
+ .pointer("/message/content")?
+ .as_array()?
+ .iter()
+ .filter(|block| block.get("type").and_then(|v| v.as_str()) == Some("text"))
+ .filter_map(|block| block.get("text")?.as_str())
+ .collect::>()
+ .join("\n\n");
+ let text = text.trim();
+ (!text.is_empty()).then(|| clip(text, MAX_LAST_MESSAGE))
+}
+
+/// The first `max` characters, with a mark when some were left out.
+fn clip(text: &str, max: usize) -> String {
+ let mut out: String = text.chars().take(max).collect();
+ if text.chars().nth(max).is_some() {
+ out.push('…');
+ }
+ out
+}
+
+/// The title a transcript line carries, if it is one that names the conversation.
+fn title_in(line: &str) -> Option {
+ if !line.contains("-title\"") && !line.contains("\"summary\"") {
+ return None;
+ }
+ let entry = serde_json::from_str::(line).ok()?;
+ let title = match entry.get("type")?.as_str()? {
+ "custom-title" => entry.get("customTitle"),
+ "ai-title" => entry.get("aiTitle"),
+ "summary" => entry.get("summary"),
+ _ => None,
+ }?
+ .as_str()?
+ .trim();
+ (!title.is_empty()).then(|| title.chars().take(MAX_TITLE).collect())
+}
+
+/// One hook event, ready to go: the line for Coucou, its name, and — for
+/// Claude's question tool only — its input exactly as it came.
+struct Event {
+ line: String,
+ name: String,
+ tool_input: Option,
+}
+
/// Reads stdin and returns the payload to forward plus the event name.
-fn read_event() -> Option<(String, String)> {
+fn read_event() -> Option {
let mut raw = Vec::new();
if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() {
return None;
@@ -128,6 +473,47 @@ fn read_event() -> Option<(String, String)> {
.unwrap_or(arg_event);
map.insert("hook_event_name".into(), serde_json::Value::String(event.clone()));
+ let tool = map.get("tool_name").and_then(|v| v.as_str()).unwrap_or_default().to_string();
+ // Before anything is cut or dropped: what an edit did, and a question whole.
+ let change = (event == "PostToolUse" && EDIT_TOOLS.contains(&tool.as_str()))
+ .then(|| map.get("tool_response").and_then(change_of))
+ .flatten();
+ // A few lines of what the tool gave back, to show under its step.
+ let result = (event == "PostToolUse")
+ .then(|| map.get("tool_response").and_then(|response| result_of(&tool, response)))
+ .flatten();
+ let tool_input = (tool == QUESTION_TOOL).then(|| map.get("tool_input").cloned()).flatten();
+ // A question that got its answers, wherever they were picked: the island's
+ // journal shows them under the question.
+ let answers = (event == "PostToolUse" && tool == QUESTION_TOOL)
+ .then(|| map.get("tool_response").and_then(|response| response.get("answers")).filter(|v| v.is_object()).cloned())
+ .flatten();
+ if let Some(answers) = answers {
+ map.insert("answers".into(), answers);
+ }
+ // An edit asking for permission: what it would do, to look at before allowing.
+ let proposal = (event == "PermissionRequest" && EDIT_TOOLS.contains(&tool.as_str()))
+ .then(|| map.get("tool_input").and_then(|input| proposal_of(&tool, input)))
+ .flatten();
+
+ let tail = TRANSCRIPT_EVENTS
+ .contains(&event.as_str())
+ .then(|| map.get("transcript_path").and_then(|v| v.as_str()).and_then(tail_of))
+ .flatten();
+ let title = tail.as_deref().and_then(title_of);
+ // A turn ends: what Claude said to end it. Claude Code hands it over when
+ // it can; the transcript has it otherwise.
+ let last_message = (event == "Stop")
+ .then(|| {
+ map.get("last_assistant_message")
+ .and_then(|v| v.as_str())
+ .map(|text| clip(text.trim(), MAX_LAST_MESSAGE))
+ .filter(|text| !text.is_empty())
+ .or_else(|| tail.as_deref().and_then(last_message_of))
+ })
+ .flatten();
+ map.remove("last_assistant_message");
+
for field in DROPPED_FIELDS {
map.remove(*field);
}
@@ -154,6 +540,8 @@ fn read_event() -> Option<(String, String)> {
("term_session_id", "TERM_SESSION_ID"),
("vscode_pid", "VSCODE_PID"),
("session_pid", "CLAUDE_CODE_SSE_PORT"),
+ // Which Claude Code this is: the desktop app, VS Code, the command line.
+ ("entrypoint", "CLAUDE_CODE_ENTRYPOINT"),
] {
if !map.contains_key(key) {
let value = std::env::var(var).unwrap_or_default();
@@ -162,10 +550,26 @@ fn read_event() -> Option<(String, String)> {
}
truncate_strings(&mut payload);
+ // After the cut: a diff is already capped, and far longer than a field.
+ if let Some(change) = change {
+ payload["change"] = change;
+ }
+ if let Some(proposal) = proposal {
+ payload["proposal"] = proposal;
+ }
+ if let Some(result) = result {
+ payload["result"] = result;
+ }
+ if let Some(title) = title {
+ payload["session_title"] = serde_json::Value::String(title);
+ }
+ if let Some(text) = last_message {
+ payload["last_message"] = serde_json::Value::String(text);
+ }
let mut line = payload.to_string();
line.push('\n');
- Some((line, event))
+ Some(Event { line, name: event, tool_input })
}
/// Caps every string in the payload. A single Write can carry a whole file.
@@ -235,6 +639,9 @@ mod tests {
);
// "always" is an island concept; Claude Code just gets an allow.
assert!(decision_json("always").unwrap().contains(r#""behavior":"allow""#));
+ // A skipped question is a denial that says what it is.
+ let skipped = decision_json("skip").unwrap();
+ assert!(skipped.contains(r#""behavior":"deny""#) && skipped.contains("skipped this question"));
}
#[test]
@@ -245,6 +652,170 @@ mod tests {
assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none());
}
+ #[test]
+ fn an_answer_goes_back_with_the_question_it_answers() {
+ let input = serde_json::json!({ "questions": [{ "question": "Which engine?" }] });
+ let out = reply_json(r#"answer {"Which engine?":"Postgres"}"#, Some(&input)).unwrap();
+ let v: serde_json::Value = serde_json::from_str(&out).unwrap();
+ let decision = &v["hookSpecificOutput"]["decision"];
+ assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PermissionRequest");
+ assert_eq!(decision["behavior"], "allow");
+ assert_eq!(decision["updatedInput"]["questions"][0]["question"], "Which engine?");
+ assert_eq!(decision["updatedInput"]["answers"]["Which engine?"], "Postgres");
+ // Without the question there is nothing to answer; a plain word still works.
+ assert!(reply_json(r#"answer {"a":"b"}"#, None).is_none());
+ assert!(reply_json("answer nonsense", Some(&input)).is_none());
+ assert_eq!(reply_json("deny", None), decision_json("deny"));
+ }
+
+ #[test]
+ fn an_edit_becomes_a_diff_with_its_line_numbers() {
+ let response = serde_json::json!({
+ "filePath": "a.rs",
+ "structuredPatch": [{
+ "oldStart": 2, "oldLines": 3, "newStart": 2, "newLines": 4,
+ "lines": [" mod claude;", "-mod files;", "+mod files;", "+mod github;", " mod hooks;"],
+ }],
+ });
+ let change = change_of(&response).unwrap();
+ assert_eq!(
+ change["patch"],
+ "@@ -2,3 +2,4 @@\n mod claude;\n-mod files;\n+mod files;\n+mod github;\n mod hooks;\n"
+ );
+ assert_eq!(change["additions"], 2);
+ assert_eq!(change["deletions"], 1);
+ assert_eq!(change["created"], false);
+ assert_eq!(change["truncated"], false);
+ }
+
+ #[test]
+ fn a_new_file_is_all_additions_and_a_long_one_is_cut() {
+ let created = serde_json::json!({ "type": "create", "content": "one\ntwo\n", "structuredPatch": [] });
+ let change = change_of(&created).unwrap();
+ assert_eq!(change["patch"], "@@ -0,0 +1,2 @@\n+one\n+two\n");
+ assert_eq!(change["created"], true);
+
+ let long = serde_json::json!({ "type": "create", "content": "line of text\n".repeat(5000), "structuredPatch": [] });
+ let change = change_of(&long).unwrap();
+ assert!(change["patch"].as_str().unwrap().len() <= MAX_PATCH + 32);
+ assert_eq!(change["truncated"], true);
+ // Counted whole, even where the diff itself was cut.
+ assert_eq!(change["additions"], 5000);
+
+ // A response with nothing to show — a read, a failed edit — is no change.
+ assert!(change_of(&serde_json::json!({ "structuredPatch": [] })).is_none());
+ assert!(change_of(&serde_json::json!("done")).is_none());
+ }
+
+ #[test]
+ fn a_proposed_edit_is_shown_where_it_lands() {
+ let before = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n";
+ let edit = serde_json::json!({ "old_string": "five", "new_string": "5\nfive and a half" });
+ let after = apply(before, &edit).unwrap();
+ let diff = diff_of(before, &after, false).unwrap();
+ assert_eq!(
+ diff["patch"],
+ "@@ -2,7 +2,8 @@\n two\n three\n four\n-five\n+5\n+five and a half\n six\n seven\n eight\n"
+ );
+ assert_eq!(diff["additions"], 2);
+ assert_eq!(diff["deletions"], 1);
+ // Text that is not in the file cannot be replaced: no proposal, the card stays plain.
+ assert!(apply(before, &serde_json::json!({ "old_string": "nine", "new_string": "9" })).is_none());
+ // Nothing changed is nothing to show.
+ assert!(diff_of(before, before, false).is_none());
+ }
+
+ #[test]
+ fn a_proposed_edit_is_read_from_the_file_and_never_written() {
+ let path = std::env::temp_dir().join(format!("coucou-hook-test-{}.txt", std::process::id()));
+ std::fs::write(&path, "alpha\r\nbeta\r\ngamma\r\n").unwrap();
+ let file = path.to_string_lossy().to_string();
+
+ let edit = serde_json::json!({ "file_path": file, "old_string": "beta", "new_string": "BETA" });
+ let proposal = proposal_of("Edit", &edit).unwrap();
+ assert_eq!(proposal["patch"], "@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n");
+ assert_eq!(proposal["created"], false);
+
+ let write = serde_json::json!({ "file_path": file, "content": "alpha\nbeta\ngamma\ndelta\n" });
+ assert_eq!(proposal_of("Write", &write).unwrap()["patch"], "@@ -1,3 +1,4 @@\n alpha\n beta\n gamma\n+delta\n");
+
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "alpha\r\nbeta\r\ngamma\r\n");
+ std::fs::remove_file(&path).unwrap();
+
+ // The file is gone now: writing it is creating it.
+ let created = proposal_of("Write", &write).unwrap();
+ assert_eq!(created["patch"], "@@ -0,0 +1,4 @@\n+alpha\n+beta\n+gamma\n+delta\n");
+ assert_eq!(created["created"], true);
+ assert!(proposal_of("Edit", &edit).is_none());
+ }
+
+ #[test]
+ fn the_title_is_the_last_one_the_transcript_names() {
+ assert_eq!(
+ title_in(r#"{"type":"custom-title","customTitle":" Panneau GitHub ","sessionId":"x"}"#).as_deref(),
+ Some("Panneau GitHub")
+ );
+ assert_eq!(title_in(r#"{"type":"summary","summary":"Fix the hook","leafUuid":"y"}"#).as_deref(), Some("Fix the hook"));
+ // A message that merely talks about titles is not one.
+ assert!(title_in(r#"{"type":"user","message":{"content":"the custom-title\" line"}}"#).is_none());
+ assert!(title_in("not json -title\"").is_none());
+
+ let path = std::env::temp_dir().join(format!("coucou-hook-title-{}.jsonl", std::process::id()));
+ std::fs::write(
+ &path,
+ "{\"type\":\"custom-title\",\"customTitle\":\"First\"}\n{\"type\":\"user\"}\n{\"type\":\"custom-title\",\"customTitle\":\"Second\"}\n{\"type\":\"assistant\"}\n",
+ )
+ .unwrap();
+ let tail = tail_of(&path.to_string_lossy()).unwrap();
+ assert_eq!(title_of(&tail).as_deref(), Some("Second"));
+ std::fs::remove_file(&path).unwrap();
+ assert!(tail_of(&path.to_string_lossy()).is_none());
+ }
+
+ #[test]
+ fn what_claude_said_last_is_its_last_message_with_words() {
+ let tail = [
+ r#"{"type":"assistant","message":{"content":[{"type":"text","text":"First answer."}]}}"#,
+ r#"{"type":"user","message":{"content":"and the \"assistant\" said?"}}"#,
+ r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"hm"},{"type":"text","text":"Done."},{"type":"text","text":"Two files changed."}]}}"#,
+ r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{}}]}}"#,
+ r#"{"type":"custom-title","customTitle":"A title"}"#,
+ ]
+ .join("\n");
+ assert_eq!(last_message_of(&tail).as_deref(), Some("Done.\n\nTwo files changed."));
+ assert!(last_message_of(r#"{"type":"user","message":{"content":"hello"}}"#).is_none());
+ assert_eq!(clip("héllo", 3), "hél…");
+ assert_eq!(clip("hey", 3), "hey");
+ }
+
+ #[test]
+ fn a_command_shows_the_end_of_what_it_printed_without_its_colours() {
+ let printed = (1..=60).map(|i| format!("line {i}")).collect::>().join("\n");
+ let response = serde_json::json!({ "stdout": format!("{printed}\n\u{1b}[32mPASS\u{1b}[0m tests\n"), "stderr": "" });
+ let result = result_of("Bash", &response).unwrap();
+ let text = result["text"].as_str().unwrap();
+ assert_eq!(text.lines().count(), MAX_RESULT_LINES);
+ assert!(text.ends_with("PASS tests"));
+ assert!(!text.contains("line 21\n"));
+ assert_eq!(result["truncated"], true);
+ assert_eq!(result["tail"], true);
+ // A command that printed nothing has nothing to show.
+ assert!(result_of("PowerShell", &serde_json::json!({ "stdout": "\n", "stderr": "" })).is_none());
+ }
+
+ #[test]
+ fn a_file_read_shows_its_first_lines_with_their_numbers() {
+ let response = serde_json::json!({ "type": "text", "file": { "content": "fn a() {}\nfn b() {}\n", "startLine": 12 } });
+ let result = result_of("Read", &response).unwrap();
+ assert_eq!(result["text"], "fn a() {}\nfn b() {}");
+ assert_eq!(result["start"], 12);
+ assert_eq!(result["tail"], false);
+ // A search shows what it found; a tool with nothing to show, nothing.
+ let found = result_of("Glob", &serde_json::json!({ "filenames": ["a.rs", "b.rs"] })).unwrap();
+ assert_eq!(found["text"], "a.rs\nb.rs");
+ assert!(result_of("WebFetch", &serde_json::json!({ "result": "…" })).is_none());
+ }
+
#[test]
fn long_strings_are_cut_on_a_char_boundary() {
let mut v = serde_json::json!({ "tool_input": { "content": "é".repeat(4000) } });
diff --git a/windows/screenshots/claude-approval.png b/windows/screenshots/claude-approval.png
new file mode 100644
index 000000000..9c8097ee2
Binary files /dev/null and b/windows/screenshots/claude-approval.png differ
diff --git a/windows/screenshots/claude-card.png b/windows/screenshots/claude-card.png
new file mode 100644
index 000000000..6f9dc5f27
Binary files /dev/null and b/windows/screenshots/claude-card.png differ
diff --git a/windows/screenshots/claude-journal.png b/windows/screenshots/claude-journal.png
new file mode 100644
index 000000000..1a423f81e
Binary files /dev/null and b/windows/screenshots/claude-journal.png differ
diff --git a/windows/screenshots/claude-question.png b/windows/screenshots/claude-question.png
new file mode 100644
index 000000000..310f930f0
Binary files /dev/null and b/windows/screenshots/claude-question.png differ
diff --git a/windows/screenshots/claude-session.png b/windows/screenshots/claude-session.png
new file mode 100644
index 000000000..3c7833631
Binary files /dev/null and b/windows/screenshots/claude-session.png differ
diff --git a/windows/screenshots/claude-sessions.png b/windows/screenshots/claude-sessions.png
new file mode 100644
index 000000000..a1d38e5bd
Binary files /dev/null and b/windows/screenshots/claude-sessions.png differ
diff --git a/windows/screenshots/github-activity.png b/windows/screenshots/github-activity.png
new file mode 100644
index 000000000..c89ec404f
Binary files /dev/null and b/windows/screenshots/github-activity.png differ
diff --git a/windows/screenshots/github-card.png b/windows/screenshots/github-card.png
new file mode 100644
index 000000000..7de441f80
Binary files /dev/null and b/windows/screenshots/github-card.png differ
diff --git a/windows/screenshots/github-news-merge.png b/windows/screenshots/github-news-merge.png
new file mode 100644
index 000000000..44bcb5200
Binary files /dev/null and b/windows/screenshots/github-news-merge.png differ
diff --git a/windows/screenshots/github-project.png b/windows/screenshots/github-project.png
new file mode 100644
index 000000000..a909b871b
Binary files /dev/null and b/windows/screenshots/github-project.png differ
diff --git a/windows/screenshots/github-run.png b/windows/screenshots/github-run.png
new file mode 100644
index 000000000..6f4d60374
Binary files /dev/null and b/windows/screenshots/github-run.png differ
diff --git a/windows/screenshots/github-settings.png b/windows/screenshots/github-settings.png
new file mode 100644
index 000000000..d9d29db48
Binary files /dev/null and b/windows/screenshots/github-settings.png differ
diff --git a/windows/screenshots/github-thread.png b/windows/screenshots/github-thread.png
new file mode 100644
index 000000000..7c47e3d37
Binary files /dev/null and b/windows/screenshots/github-thread.png differ
diff --git a/windows/screenshots/spotify-announce.png b/windows/screenshots/spotify-announce.png
new file mode 100644
index 000000000..4bf252e29
Binary files /dev/null and b/windows/screenshots/spotify-announce.png differ
diff --git a/windows/screenshots/spotify-card.png b/windows/screenshots/spotify-card.png
new file mode 100644
index 000000000..23a7561b9
Binary files /dev/null and b/windows/screenshots/spotify-card.png differ
diff --git a/windows/screenshots/spotify-folded.png b/windows/screenshots/spotify-folded.png
new file mode 100644
index 000000000..9dfad2fa2
Binary files /dev/null and b/windows/screenshots/spotify-folded.png differ
diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml
index f581867d9..0458680ff 100644
--- a/windows/src-tauri/Cargo.toml
+++ b/windows/src-tauri/Cargo.toml
@@ -25,6 +25,10 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
[target.'cfg(windows)'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
windows = { version = "0.61", features = [
+ "Foundation",
+ "Foundation_Collections",
+ "Media_Control",
+ "Storage_Streams",
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
diff --git a/windows/src-tauri/src/github.rs b/windows/src-tauri/src/github.rs
new file mode 100644
index 000000000..e84b8b839
--- /dev/null
+++ b/windows/src-tauri/src/github.rs
@@ -0,0 +1,2310 @@
+// GitHub — everything behind the GitHub pill: the panel's data (profile, recent
+// activity, projects and their last Actions run), the client, the rate-limit
+// bookkeeping and the connection test in the settings window.
+//
+// The token is a fine-grained, read-only personal access token kept in the
+// Credential Manager under `github-token`. It goes into the Authorization header
+// and nowhere else: never into a URL, never into the log, never back to the
+// front end.
+//
+// When things happen: integrations.rs ticks every two minutes, island shown or
+// not — the tick is what tells the pill a build broke — and `watch_live` every
+// twenty seconds while a run is going. The Refresh button and opening the
+// panel refresh too. Sheets are fetched on the click only.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{LazyLock, Mutex};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use reqwest::header::HeaderMap;
+use reqwest::{Method, RequestBuilder};
+use serde::Serialize;
+use serde_json::{json, Value};
+use tauri::{AppHandle, Manager};
+
+use crate::github_detail::Target;
+use crate::integrations::{emit, IntegrationEvent, IntegrationUpdate};
+use crate::log;
+use crate::secrets;
+
+const ID: &str = "integration_github";
+
+const API: &str = "https://api.github.com";
+/// GitHub's own site: a page's address is built on it when GitHub gave no link.
+pub(crate) const WEB: &str = "https://github.com";
+/// Pinned so a change of default on GitHub's side can't reshape the answers.
+const API_VERSION: &str = "2022-11-28";
+const TIMEOUT: Duration = Duration::from_secs(10);
+pub(crate) const TOKEN_KEY: &str = "github-token";
+/// The wait GitHub's documentation asks for when it gives no length: a minute.
+const MINUTE: u64 = 60;
+/// How much of GitHub's own words about a failed query the log keeps.
+const LOG_CHARS: usize = 160;
+const ETAG: &str = "etag";
+
+/// What stands in for a name GitHub left out.
+pub(crate) const UNTITLED: &str = "Untitled";
+pub(crate) const WORKFLOW: &str = "Workflow";
+const OTHER: &str = "Other";
+const CREATED: &str = "Created the repository";
+
+/// A commit as people write it: the first seven characters of its SHA.
+pub(crate) const SHORT_SHA: usize = 7;
+
+pub(crate) fn short_sha(sha: &str) -> String {
+ sha.chars().take(SHORT_SHA).collect()
+}
+
+/// The first line of a message: a commit's title, a run's.
+pub(crate) fn first_line(message: String) -> Option {
+ message.lines().next().map(str::to_string)
+}
+
+/// A count GitHub gave, or zero: `pointer` is a JSON pointer into `node`.
+pub(crate) fn int(node: &Value, pointer: &str) -> i64 {
+ node.pointer(pointer).and_then(Value::as_i64).unwrap_or(0)
+}
+
+/// A yes or no GitHub gave, or no.
+pub(crate) fn flag(node: &Value, key: &str) -> bool {
+ node.get(key).and_then(Value::as_bool).unwrap_or(false)
+}
+
+/// The entries of a list in an answer: `pointer` leads to the array.
+pub(crate) fn nodes<'a>(value: &'a Value, pointer: &str) -> &'a [Value] {
+ value.pointer(pointer).and_then(Value::as_array).map_or(&[], Vec::as_slice)
+}
+
+/// The token's permissions, by the names GitHub's settings page gives them:
+/// what a sheet says it is missing, and what the connection test checks.
+pub(crate) mod permission {
+ pub const ACTIONS: &str = "Actions";
+ pub const CONTENTS: &str = "Contents";
+ pub const DEPLOYMENTS: &str = "Deployments";
+ pub const ISSUES: &str = "Issues";
+ pub const PULL_REQUESTS: &str = "Pull requests";
+}
+
+/// Where a pull request stands — open, draft, merged or closed.
+pub(crate) fn pull_state(node: &Value) -> &'static str {
+ match node.get("state").and_then(Value::as_str) {
+ Some("MERGED") => "merged",
+ Some("CLOSED") => "closed",
+ _ if flag(node, "isDraft") => "draft",
+ _ => "open",
+ }
+}
+
+/// What its reviews add up to — approved, changes requested or review
+/// required; None when no review was asked for.
+pub(crate) fn review_decision(node: &Value) -> Option<&'static str> {
+ match node.get("reviewDecision").and_then(Value::as_str) {
+ Some("APPROVED") => Some("approved"),
+ Some("CHANGES_REQUESTED") => Some("changes requested"),
+ Some("REVIEW_REQUIRED") => Some("review required"),
+ _ => None,
+ }
+}
+
+/// Answers kept for a while under a key: asked for again soon enough, the
+/// kept one is given back. What has outlived every use is dropped as new
+/// answers come in, so the map holds what was looked at lately, not
+/// everything ever opened.
+pub(crate) struct Kept(Mutex>);
+
+/// Past this no kept answer is still good: the longest any of them is trusted.
+const KEPT_AT_MOST: u64 = PAST_DAY_TTL;
+
+impl Kept {
+ pub(crate) fn new() -> Self {
+ Self(Mutex::new(HashMap::new()))
+ }
+
+ /// The answer under `key`, if it is younger than `ttl` says it may be.
+ pub(crate) fn fresh(&self, key: &str, now: u64, ttl: impl FnOnce(&T) -> u64) -> Option {
+ let kept = self.0.lock().unwrap();
+ let (at, value) = kept.get(key)?;
+ (now.saturating_sub(*at) < ttl(value)).then(|| value.clone())
+ }
+
+ pub(crate) fn keep(&self, key: String, now: u64, value: T) {
+ let mut kept = self.0.lock().unwrap();
+ kept.retain(|_, (at, _)| now.saturating_sub(*at) < KEPT_AT_MOST);
+ kept.insert(key, (now, value));
+ }
+
+ pub(crate) fn clear(&self) {
+ self.0.lock().unwrap().clear();
+ }
+}
+
+/// "owner/name" → "name".
+pub(crate) fn short_name(full_name: &str) -> &str {
+ full_name.rsplit('/').next().unwrap_or(full_name)
+}
+
+/// Why a GitHub call gave nothing usable. `message()` is what the island and the
+/// settings window show, so it has to make sense to someone who never saw an
+/// HTTP status code.
+#[derive(Debug, PartialEq)]
+pub enum GhError {
+ NoToken,
+ /// 401 — wrong, revoked or expired token.
+ Unauthorized,
+ /// 403 that is not a rate limit: the token can't see this.
+ Forbidden,
+ NotFound,
+ /// Nothing goes out before this Unix time (seconds).
+ RateLimited(u64),
+ Offline,
+ Status(u16),
+ BadResponse,
+}
+
+impl GhError {
+ pub fn message(&self) -> String {
+ match self {
+ GhError::NoToken => "No token saved — Settings → GitHub".into(),
+ GhError::Unauthorized => "Token refused (401): invalid or expired".into(),
+ GhError::Forbidden => "Missing permission (403)".into(),
+ GhError::NotFound => "Not found (404)".into(),
+ GhError::RateLimited(until) => {
+ format!("GitHub rate limit — {}", wait_text(*until, unix_now()))
+ }
+ GhError::Offline => "No connection".into(),
+ GhError::Status(code) => format!("GitHub error {code}"),
+ GhError::BadResponse => "Unexpected answer from GitHub".into(),
+ }
+ }
+}
+
+impl From for String {
+ fn from(e: GhError) -> String {
+ e.message()
+ }
+}
+
+pub(crate) fn unix_now() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// "back in 12 min" — relative, so no time zone is involved.
+fn wait_text(until: u64, now: u64) -> String {
+ let minutes = until.saturating_sub(now).div_ceil(60);
+ if minutes <= 1 {
+ "back in a minute".into()
+ } else {
+ format!("back in {minutes} min")
+ }
+}
+
+// ── Rate limit ────────────────────────────────────────────────────────────────
+
+/// Set from GitHub's own headers. Until then no GitHub request leaves at all:
+/// pushing on after a rate-limit answer is what gets a token blocked for longer.
+static BLOCKED_UNTIL: Mutex = Mutex::new(0);
+
+fn blocked(now: u64) -> Option {
+ let until = *BLOCKED_UNTIL.lock().unwrap();
+ (until > now).then_some(until)
+}
+
+fn block_until(until: u64) {
+ let mut current = BLOCKED_UNTIL.lock().unwrap();
+ *current = (*current).max(until);
+}
+
+/// Until when GitHub asked us to stay quiet, read from one answer.
+///
+/// Primary limit: `x-ratelimit-remaining` reaches 0 and `x-ratelimit-reset` says
+/// when the window reopens — even on a 200, since the next call would fail.
+/// Secondary limit: a 403/429 with `retry-after`, or with no hint at all, in
+/// which case GitHub's documentation says to wait at least a minute.
+fn rate_block(
+ status: u16,
+ remaining: Option,
+ reset: Option,
+ retry_after: Option,
+ says_rate_limit: bool,
+ now: u64,
+) -> Option {
+ let refused = status == 403 || status == 429;
+ if refused {
+ if let Some(secs) = retry_after {
+ return Some(now + secs.max(1));
+ }
+ }
+ if remaining == Some(0) {
+ return Some(reset.filter(|r| *r > now).unwrap_or(now + MINUTE));
+ }
+ if status == 429 || (refused && says_rate_limit) {
+ return Some(now + MINUTE);
+ }
+ None
+}
+
+fn header_u64(headers: &HeaderMap, name: &str) -> Option {
+ headers.get(name)?.to_str().ok()?.trim().parse().ok()
+}
+
+fn header_str(headers: &HeaderMap, name: &str) -> Option {
+ headers
+ .get(name)?
+ .to_str()
+ .ok()
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+}
+
+// ── Client ────────────────────────────────────────────────────────────────────
+
+pub struct Reply {
+ pub json: Value,
+ pub headers: HeaderMap,
+}
+
+pub struct Gh {
+ token: String,
+}
+
+/// One client for every call, so a connection to GitHub opened for one is
+/// there for the next: the token is what changes, and it rides in a header.
+static HTTP: LazyLock =
+ LazyLock::new(|| reqwest::Client::builder().timeout(TIMEOUT).build().unwrap_or_default());
+
+impl Gh {
+ /// Reads the token from the Credential Manager, each time something is
+ /// asked: a token changed in the settings window is the one used next.
+ pub fn from_store() -> Result {
+ Ok(Self { token: secrets::get(TOKEN_KEY).ok_or(GhError::NoToken)? })
+ }
+
+ fn request(&self, method: Method, url: &str) -> RequestBuilder {
+ HTTP.request(method, url)
+ .header("Authorization", format!("Bearer {}", self.token))
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", API_VERSION)
+ .header("User-Agent", "Coucou")
+ }
+
+ /// REST GET. `path` starts with `/`.
+ pub async fn get(&self, path: &str) -> Result {
+ let request = self.request(Method::GET, &format!("{API}{path}"));
+ self.send(request, path).await?.ok_or(GhError::BadResponse)
+ }
+
+ /// REST GET that sends back the ETag of the previous answer. `None` means
+ /// "unchanged since then" — a 304, which GitHub does not count against the
+ /// rate limit, and the reason polling stays free when nothing happens.
+ pub async fn get_if_changed(&self, path: &str, etag: Option<&str>) -> Result