diff --git a/PLAN.md b/PLAN.md
index c8e60ad..fd84044 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -128,6 +128,26 @@ correctly instead of needing special cases.
The other half of the product, and the reason reordering exists: collect a scattered set of values,
arrange them, and put them back into one document in the right order.
+### Choosing which clips are in play
+
+A spool is not always wanted whole. Ticking clips narrows what the next serve delivers and what the
+whole-spool paste joins — the same working set for both, so `Win+Alt+U`, `Win+Alt+V` and the button
+can never mean different things.
+
+**An empty selection means every clip.** Selecting nothing and meaning nothing is not a state worth
+having: it would make both hotkeys dead and the button a no-op, with nothing to distinguish that
+from a bug. So clearing the selection and selecting everything are the same act, and the app offers
+one command rather than two that disagree at the edges. Unticking the last box returns to all.
+
+The selection is **not stored**. It describes what you are doing now, the way a text selection does,
+and one that survived a restart would be a rule the user does not remember making. It ends when the
+active spool changes, when the spool is cleared, and it drops any clip that is deleted — a set still
+naming a clip that is gone would make the button promise more than it can deliver.
+
+Serving steps over what is not in play, in both directions, and wraps among the chosen clips. A
+cursor sitting on an excluded clip is not an error: it was put there before the choice was made, so
+the next serve walks forward to the first clip that is in play rather than refusing.
+
**It joins and writes once.** Every clip in the spool is concatenated with a separator and written to
the system clipboard as a single item. The user then pastes normally, once. The alternative —
synthesising one paste per clip — is rejected for the same reason serve-and-paste is (§8): it needs
@@ -190,9 +210,31 @@ on a device with far less room. See §10 for the arithmetic.
## 4. Sensitive clips
-Two tiers, with different confidence and different wording.
+One signal: what the source application declared.
+
+**Tier 2 was removed.** The app used to also guess from content — PEM blocks, JWTs, key prefixes
+like `sk-` and `AKIA`, connection-string keywords, high-entropy strings — and prompt on a match. It
+went for three reasons, in order of weight.
-### Tier 1 — Declared (authoritative)
+It interrupted an ordinary workflow to say something the user already knew. **Copying a credential
+is a normal thing to do**, and the prompt arrived every time, asking permission for the thing the
+person had just deliberately done.
+
+The premise was weaker than it looked. The heuristics existed against a risk of *exposure*, but
+nothing Spool holds leaves the machine — the guarantee of §5 is the whole product. What Spool does
+change is **persistence**: a clipboard entry that would have lived until the next copy instead lives
+in an encrypted file with a visible preview. That is a real difference, and it is the honest case
+for asking. It is not a strong enough one to justify asking about every API key a developer copies.
+
+And it was the entire cost of capture: **147ms per MiB**, because each needle walked the whole
+buffer separately. Removing it took classification from 147ms to 0.003ms, because it no longer reads
+the content at all — `classify` does not take the bytes any more, which is the strongest form that
+claim can take. Two tests that failed intermittently at a five-second timeout stopped being flaky as
+a side effect.
+
+What is kept is **not a guess**, and that distinction is the whole of the decision.
+
+### What the application declared (authoritative)
The source application marked the clipboard content as secret. Password managers do this.
@@ -202,6 +244,12 @@ The source application marked the clipboard content as secret. Password managers
Prompt names the source: *"1Password marked this as concealed. Keep it in this spool?"*
+`CanIncludeInClipboardHistory = 0` is an explicit statement from the application that owns the
+secret, saying *do not persist this*, and **Windows' own Clipboard History obeys it**. Spool makes a
+transient thing durable, so ignoring that request would persist exactly what a password manager asked
+it not to, and leave Spool behaving worse than the operating-system feature beside it. It costs a
+flag check and no scanning, which is why it survives the argument that removed the other tier.
+
### Tier 2 — Heuristic (advisory)
Pattern or entropy match. Lower confidence, softer wording: *"This looks like a secret."*
diff --git a/src/main/clipboard/capture.ts b/src/main/clipboard/capture.ts
index 0572d4c..4c2fd81 100644
--- a/src/main/clipboard/capture.ts
+++ b/src/main/clipboard/capture.ts
@@ -117,13 +117,10 @@ export function captureSnapshot(
}
}
- const sensitivity = classify(
- {
- formats: snapshot.formats,
- canIncludeInClipboardHistory: snapshot.canIncludeInClipboardHistory ?? null
- },
- bytes
- )
+ const sensitivity = classify({
+ formats: snapshot.formats,
+ canIncludeInClipboardHistory: snapshot.canIncludeInClipboardHistory ?? null
+ })
const decision = decideConsent(sensitivity, snapshot.sourceApp ?? null, cleared.sourceRules)
if (decision.kind === 'skip') {
diff --git a/src/main/core/join.ts b/src/main/core/join.ts
index a52c597..4931bef 100644
--- a/src/main/core/join.ts
+++ b/src/main/core/join.ts
@@ -1,3 +1,4 @@
+import { selectedClips } from './selection'
import type { Spool } from './types'
/**
@@ -66,10 +67,16 @@ export type JoinResult =
* would silently drop the clips behind it. The cursor does not move: this is a bulk read, not a
* traversal.
*/
-export function joinSpool(spool: Spool, separator: SeparatorKind): JoinResult {
- if (spool.clips.length === 0) return { ok: false, reason: 'empty' }
+export function joinSpool(
+ spool: Spool,
+ separator: SeparatorKind,
+ selection: ReadonlySet = new Set()
+): JoinResult {
+ // A selection narrows what is joined; empty means every clip, so the ordinary case is unchanged.
+ const chosen = selectedClips(spool.clips, selection)
+ if (chosen.length === 0) return { ok: false, reason: 'empty' }
- const ordered = spool.mode === 'fifo' ? spool.clips : [...spool.clips].reverse()
+ const ordered = spool.mode === 'fifo' ? chosen : [...chosen].reverse()
const text = ordered.map((clip) => clip.content).join(separatorText(separator))
return {
diff --git a/src/main/core/selection.test.ts b/src/main/core/selection.test.ts
new file mode 100644
index 0000000..f89f3a4
--- /dev/null
+++ b/src/main/core/selection.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { isSelected, prune, selectedClips, selectedCount, toggle } from './selection'
+import type { Clip } from './types'
+
+const clip = (id: string): Clip => ({
+ id,
+ content: id,
+ preview: id,
+ byteLength: id.length,
+ sourceApp: null,
+ wasFlagged: false,
+ capturedAt: '2026-09-11T00:00:00.000Z'
+})
+
+const clips = [clip('a'), clip('b'), clip('c')]
+
+describe('an empty selection means every clip (PLAN.md 3)', () => {
+ // The rule the whole feature rests on: selecting nothing and meaning nothing is not a state worth
+ // having, because it would make the hotkeys dead with no way to tell that apart from a bug.
+ it('treats every clip as in play', () => {
+ const none = new Set()
+ expect(clips.every((c) => isSelected(c.id, none))).toBe(true)
+ expect(selectedClips(clips, none)).toEqual(clips)
+ expect(selectedCount(clips, none)).toBe(3)
+ })
+
+ it('narrows to exactly what was chosen once something is', () => {
+ const some = new Set(['a', 'c'])
+ expect(selectedClips(clips, some).map((c) => c.id)).toEqual(['a', 'c'])
+ expect(selectedCount(clips, some)).toBe(2)
+ expect(isSelected('b', some)).toBe(false)
+ })
+
+ it('keeps spool order rather than the order things were ticked', () => {
+ expect(selectedClips(clips, new Set(['c', 'a'])).map((c) => c.id)).toEqual(['a', 'c'])
+ })
+})
+
+describe('toggle', () => {
+ it('adds and removes one clip', () => {
+ expect([...toggle(new Set(), 'b')]).toEqual(['b'])
+ expect([...toggle(new Set(['b']), 'b')]).toEqual([])
+ })
+
+ // Unticking the last box returns to all, which is the same state as never having ticked one.
+ // Clearing the selection and selecting everything are one act, so they cannot disagree.
+ it('emptying the selection is the same as selecting all', () => {
+ const emptied = toggle(new Set(['b']), 'b')
+ expect(selectedCount(clips, emptied)).toBe(3)
+ })
+})
+
+describe('prune', () => {
+ it('drops ids for clips that are gone', () => {
+ expect([...prune([clip('a')], new Set(['a', 'b']))]).toEqual(['a'])
+ })
+
+ // A selection still holding a deleted clip would make the button promise more than it can give.
+ it('a selection emptied by deletion means all again, not none', () => {
+ expect(selectedCount([clip('a')], prune([clip('a')], new Set(['gone'])))).toBe(1)
+ })
+})
diff --git a/src/main/core/selection.ts b/src/main/core/selection.ts
new file mode 100644
index 0000000..4bdb41d
--- /dev/null
+++ b/src/main/core/selection.ts
@@ -0,0 +1,51 @@
+import type { Clip } from './types'
+
+/**
+ * Which clips are in play (PLAN.md 3).
+ *
+ * A selection is a working set, not a property of the clips: it says which of them the next serve,
+ * the next whole-spool paste, and the button that offers it should consider. It is deliberately not
+ * stored — it is about what you are doing right now, the way a text selection is, and a selection
+ * that survived a restart would be a rule the user does not remember making.
+ *
+ * **An empty selection means every clip.** Selecting nothing and meaning nothing is not a state
+ * worth having: it would make the hotkeys dead and the button a no-op, with no way to tell that
+ * apart from a bug. So clearing the selection and selecting everything are the same act, and the
+ * app says so rather than offering both.
+ */
+export function isSelected(clipId: string, selection: ReadonlySet): boolean {
+ return selection.size === 0 || selection.has(clipId)
+}
+
+/** The clips a serve or a join should consider, in spool order. */
+export function selectedClips(
+ clips: readonly Clip[],
+ selection: ReadonlySet
+): readonly Clip[] {
+ return selection.size === 0 ? clips : clips.filter((clip) => selection.has(clip.id))
+}
+
+/** How many clips are in play, which is what the button has to name. */
+export function selectedCount(clips: readonly Clip[], selection: ReadonlySet): number {
+ return selectedClips(clips, selection).length
+}
+
+/**
+ * Drop ids that are no longer in the spool.
+ *
+ * A selection holding a deleted clip would keep counting it, so the button would promise more than
+ * it could deliver — and an id that came back on a later clip would silently select something the
+ * user never chose.
+ */
+export function prune(clips: readonly Clip[], selection: ReadonlySet): Set {
+ const present = new Set(clips.map((clip) => clip.id))
+ return new Set([...selection].filter((id) => present.has(id)))
+}
+
+/** Add or remove one clip. Removing the last one empties the selection, which means all again. */
+export function toggle(selection: ReadonlySet, clipId: string): Set {
+ const next = new Set(selection)
+ if (next.has(clipId)) next.delete(clipId)
+ else next.add(clipId)
+ return next
+}
diff --git a/src/main/core/spool.ts b/src/main/core/spool.ts
index 4cbbd6d..eb07b07 100644
--- a/src/main/core/spool.ts
+++ b/src/main/core/spool.ts
@@ -1,5 +1,6 @@
import { byteLength } from './clip'
import { CLIP_BYTE_CAP, DEFAULT_SPOOL_CLIP_CAP, SAVED_SPOOL_CLIP_CAP } from './limits'
+import { isSelected } from './selection'
import type { CaptureResult, Clip, Mode, ServeResult, Spool, SpoolKind } from './types'
/** The clip cap this spool is bound by (PLAN.md 3, Limits). */
@@ -100,14 +101,37 @@ export function capture(spool: Spool, clip: Clip): CaptureResult {
* Write the cursor's clip out and advance (PLAN.md 3). **Serving pastes; it does not pop** — the
* clip stays exactly where it was, and the cursor moves one step in the mode's direction, wrapping
* at the end.
+ *
+ * A selection narrows what is in play: unselected clips are stepped over, so unspooling walks the
+ * chosen ones in the mode's order and wraps among them. An empty selection means every clip, so
+ * the ordinary case costs nothing.
*/
-export function serve(spool: Spool): ServeResult {
- const index = cursorIndex(spool)
- if (index === -1) return { ok: false, reason: 'empty', spool }
+export function serve(spool: Spool, selection: ReadonlySet = new Set()): ServeResult {
+ const count = spool.clips.length
+ if (count === 0) return { ok: false, reason: 'empty', spool }
+
+ const eligible = (clip: Clip): boolean => isSelected(clip.id, selection)
+ if (!spool.clips.some(eligible)) return { ok: false, reason: 'empty', spool }
+
+ const start = cursorIndex(spool)
+ if (start === -1) return { ok: false, reason: 'empty', spool }
+ const direction = step(spool.mode)
+
+ // The cursor may be sitting on a clip the selection excludes — it was put there before the
+ // selection was made. Walk to the first one that is in play rather than refusing to serve.
+ const nextEligible = (from: number): number => {
+ let at = from
+ for (let taken = 0; taken < count; taken++) {
+ if (eligible(spool.clips[at])) return at
+ at = (at + direction + count) % count
+ }
+ return from
+ }
+
+ const index = nextEligible(start)
const clip = spool.clips[index]
- const count = spool.clips.length
- const next = (index + step(spool.mode) + count) % count
+ const next = nextEligible((index + direction + count) % count)
return { ok: true, clip, spool: { ...spool, cursorClipId: spool.clips[next].id } }
}
diff --git a/src/main/detect/bytes.ts b/src/main/detect/bytes.ts
index 63f3fb1..a858b61 100644
--- a/src/main/detect/bytes.ts
+++ b/src/main/detect/bytes.ts
@@ -1,117 +1,15 @@
/**
- * Byte-level helpers for the sensitivity detectors (PLAN.md 4).
+ * Working on clipboard bytes rather than strings (PLAN.md 4).
*
- * These work on `Uint8Array` and never build a string, which is the whole point: a JavaScript
- * string is immutable and garbage-collected, so a secret that becomes one cannot be wiped and may
- * outlive the user's decision — possibly into a swap file. Detection therefore happens on the bytes
- * the addon handed over, and the bytes are what gets zeroed on Skip.
+ * This module was once a small byte-searching library — `ascii`, `startsWith`, `includes`,
+ * `indexOf`, `trim`, `isDigit`, `hasWhitespace`, `characterClasses`, `shannonEntropy` — built so the
+ * secret heuristics could scan a copy without ever turning it into a string. The heuristics were
+ * removed, and every one of those went with them: there is nothing left that reads the content.
*
- * ASCII-only comparisons are enough for every pattern in §4: PEM headers, base64url, key prefixes,
- * and connection-string keywords are all ASCII, and UTF-8 encodes ASCII as itself, so a multi-byte
- * character can never be mistaken for one of them.
+ * `wipe` stays, and it is the one that mattered. A clip the user declines must not be left in
+ * memory, and zeroing the buffer is the only thing this file does now.
*/
-const encoder = new TextEncoder()
-
-/** The ASCII bytes of a literal, for comparing against clipboard content. */
-export function ascii(literal: string): Uint8Array {
- return encoder.encode(literal)
-}
-
-const isUpper = (byte: number): boolean => byte >= 0x41 && byte <= 0x5a
-const isLower = (byte: number): boolean => byte >= 0x61 && byte <= 0x7a
-
-/** Lowercase one ASCII byte, leaving everything else alone. */
-const foldCase = (byte: number): number => (isUpper(byte) ? byte + 0x20 : byte)
-
-export function isWhitespace(byte: number): boolean {
- return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d || byte === 0x0b
-}
-
-export function isDigit(byte: number): boolean {
- return byte >= 0x30 && byte <= 0x39
-}
-
-/** Does `haystack` begin with `needle`, ignoring leading whitespace? */
-export function startsWith(haystack: Uint8Array, needle: Uint8Array): boolean {
- let start = 0
- while (start < haystack.length && isWhitespace(haystack[start])) start += 1
- if (haystack.length - start < needle.length) return false
-
- for (let i = 0; i < needle.length; i += 1) {
- if (haystack[start + i] !== needle[i]) return false
- }
- return true
-}
-
-/** Does `haystack` contain `needle` anywhere? `fold` compares case-insensitively. */
-export function includes(haystack: Uint8Array, needle: Uint8Array, fold = false): boolean {
- return indexOf(haystack, needle, fold) !== -1
-}
-
-export function indexOf(haystack: Uint8Array, needle: Uint8Array, fold = false, from = 0): number {
- if (needle.length === 0 || haystack.length < needle.length) return -1
-
- outer: for (let i = from; i <= haystack.length - needle.length; i += 1) {
- for (let j = 0; j < needle.length; j += 1) {
- const a = fold ? foldCase(haystack[i + j]) : haystack[i + j]
- const b = fold ? foldCase(needle[j]) : needle[j]
- if (a !== b) continue outer
- }
- return i
- }
- return -1
-}
-
-/** The content with leading and trailing whitespace removed — a view, not a copy. */
-export function trim(bytes: Uint8Array): Uint8Array {
- let start = 0
- let end = bytes.length
- while (start < end && isWhitespace(bytes[start])) start += 1
- while (end > start && isWhitespace(bytes[end - 1])) end -= 1
- return bytes.subarray(start, end)
-}
-
-export function hasWhitespace(bytes: Uint8Array): boolean {
- for (const byte of bytes) if (isWhitespace(byte)) return true
- return false
-}
-
-/** How many of the four character classes appear: lower, upper, digit, and everything else. */
-export function characterClasses(bytes: Uint8Array): number {
- let lower = false
- let upper = false
- let digit = false
- let symbol = false
-
- for (const byte of bytes) {
- if (isLower(byte)) lower = true
- else if (isUpper(byte)) upper = true
- else if (isDigit(byte)) digit = true
- else symbol = true
- }
-
- return [lower, upper, digit, symbol].filter(Boolean).length
-}
-
-/**
- * Shannon entropy in bits per byte. Random-looking material scores high; English prose and
- * repetitive identifiers score low.
- */
-export function shannonEntropy(bytes: Uint8Array): number {
- if (bytes.length === 0) return 0
-
- const counts = new Map()
- for (const byte of bytes) counts.set(byte, (counts.get(byte) ?? 0) + 1)
-
- let entropy = 0
- for (const count of counts.values()) {
- const probability = count / bytes.length
- entropy -= probability * Math.log2(probability)
- }
- return entropy
-}
-
/**
* Zero the bytes and drop them. Best-effort, and worth describing as exactly that: it is defeated
* by a process dump or a swapped page, and it is still far better than letting a declined password
diff --git a/src/main/detect/consent.ts b/src/main/detect/consent.ts
index 21d9051..2f46553 100644
--- a/src/main/detect/consent.ts
+++ b/src/main/detect/consent.ts
@@ -61,25 +61,21 @@ export function keepsTheClip(choice: ConsentChoice): boolean {
*/
export const CONSENT_TIMEOUT_MS = 30_000
-/** How the prompt reads. Tier 1 names the source; Tier 2 is softer, because it is a guess. */
+/**
+ * How the prompt reads. It always names the source, because the app itself is what raised this —
+ * there is no longer a softer wording for a guess, because there are no guesses.
+ */
export function promptWording(
sensitivity: Sensitivity,
sourceApp: string | null
): { headline: string; detail: string } {
const application = sourceApp === null ? null : sourceApp.replace(/\.exe$/i, '')
- if (sensitivity.tier === 1) {
- return {
- headline:
- application === null
- ? 'That copy was marked as concealed. Keep it in this spool?'
- : `${application} marked this as concealed. Keep it in this spool?`,
- detail: sensitivity.rule
- }
- }
-
return {
- headline: 'This looks like a secret. Keep it in this spool?',
- detail: `It looks like ${sensitivity.rule}.`
+ headline:
+ application === null
+ ? 'That copy was marked as concealed. Keep it in this spool?'
+ : `${application} marked this as concealed. Keep it in this spool?`,
+ detail: sensitivity.rule
}
}
diff --git a/src/main/detect/sensitivity.test.ts b/src/main/detect/sensitivity.test.ts
index 1409d21..3ae2540 100644
--- a/src/main/detect/sensitivity.test.ts
+++ b/src/main/detect/sensitivity.test.ts
@@ -1,23 +1,20 @@
import { describe, expect, it } from 'vitest'
-import { wipe } from './bytes'
-import { classify, declaredConcealed, looksLikeSecret } from './sensitivity'
+import { classify, declaredConcealed } from './sensitivity'
-const bytes = (text: string): Uint8Array => new TextEncoder().encode(text)
-
-describe('Tier 1 — declared (PLAN.md 4)', () => {
+describe('what the application declared (PLAN.md 4)', () => {
it('trusts the Windows exclusion format', () => {
const result = declaredConcealed({
formats: ['CF_UNICODETEXT', 'ExcludeClipboardContentFromMonitorProcessing'],
canIncludeInClipboardHistory: null
})
- expect(result?.tier).toBe(1)
+ expect(result?.rule).toMatch(/concealed/)
})
it('trusts CanIncludeInClipboardHistory when it says no', () => {
expect(
- declaredConcealed({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 })?.tier
- ).toBe(1)
+ declaredConcealed({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 })?.rule
+ ).toMatch(/clipboard history/)
})
it('does not fire when that format says yes', () => {
@@ -31,8 +28,8 @@ describe('Tier 1 — declared (PLAN.md 4)', () => {
declaredConcealed({
formats: ['public.utf8-plain-text', 'org.nspasteboard.ConcealedType'],
canIncludeInClipboardHistory: null
- })?.tier
- ).toBe(1)
+ })?.rule
+ ).toMatch(/concealed/)
})
it('says nothing about an ordinary copy', () => {
@@ -40,92 +37,30 @@ describe('Tier 1 — declared (PLAN.md 4)', () => {
.toBeNull()
})
- it('beats a Tier 2 guess, because one is a statement and the other is a shape', () => {
- const result = classify(
- { formats: ['ExcludeClipboardContentFromMonitorProcessing'], canIncludeInClipboardHistory: 0 },
- bytes('just some ordinary text')
- )
-
- expect(result?.tier).toBe(1)
- })
-})
-
-describe('Tier 2 — heuristics (PLAN.md 4)', () => {
- it.each([
- ['a PEM block', '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END'],
- ['a JWT', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSJ9.dBjftJeZ4CVPmB92K'],
- ['an OpenAI key', 'sk-proj-abc123def456ghi789jkl012mno345pqr'],
- ['an AWS access key', 'AKIAIOSFODNN7EXAMPLE'],
- ['a GitHub token', 'ghp_16C7e42F292c6912E7710c838347Ae178B4a'],
- ['a GitHub fine-grained token', 'github_pat_11ABCDEFG0abcdefghijkl_mnopqrstuvwxyz'],
- ['a Slack token', 'xoxb-123456789012-1234567890123-abcdefgh'],
- ['a Google API key', 'AIzaSyD-abc123DEF456ghi789JKL012mno345PQ'],
- ['a SQL Server connection string', 'Server=tcp:db.example.com;Database=app;Password=hunter2;'],
- ['a lowercase pwd= connection string', 'host=db;user=app;pwd=s3cret;'],
- ['a random-looking secret', 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY']
- ])('flags %s', (_name, content) => {
- expect(looksLikeSecret(bytes(content))?.tier).toBe(2)
- })
-
- it('names which rule matched, so the prompt can say why', () => {
- expect(looksLikeSecret(bytes('AKIAIOSFODNN7EXAMPLE'))?.rule).toMatch(/AWS/)
- expect(looksLikeSecret(bytes('-----BEGIN CERTIFICATE-----'))?.rule).toMatch(/PEM/)
- })
-})
-
-describe('Tier 2 negatives — what must not trip (PLAN.md 11, M5)', () => {
- it.each([
- ['ordinary prose', 'The quick brown fox jumps over the lazy dog, and then does it again.'],
- ['a single sentence', 'Remember to call the plumber about the leak on Tuesday morning.'],
- ['a URL', 'https://github.com/willkotheimer/Spool/blob/main/PLAN.md#milestones'],
- ['a long URL with a query', 'https://example.com/search?q=clipboard+manager&page=2&sort=recent'],
- ['a bare domain', 'www.example.com/some/deep/path/to/a/document'],
- ['a code snippet', 'const spool = createSpool({ id: "default", mode: "fifo" })'],
- ['an import line', "import { captureSnapshot } from './clipboard/capture'"],
- ['a camelCase identifier', 'getUserAccountSettingsFromDatabase'],
- ['a file path', 'C:/Users/wkoth/source/repos/Spool/src/main/detect'],
- ['a short word', 'password'],
- ['a phone number', '+1 (555) 010-9999'],
- ['an email address', 'someone@example.com'],
- ['a hex colour', '#3b82f6'],
- ['a date', '2026-08-22T15:00:00.000Z']
- ])('leaves %s alone', (_name, content) => {
- expect(looksLikeSecret(bytes(content))).toBeNull()
- })
-
- it('leaves an empty or blank clipboard alone', () => {
- expect(looksLikeSecret(bytes(''))).toBeNull()
- expect(looksLikeSecret(bytes(' \n '))).toBeNull()
- })
-})
-
-describe('wiping (PLAN.md 4)', () => {
- it('zeroes the bytes in place, so the buffer that held a secret no longer does', () => {
- const secret = bytes('AKIAIOSFODNN7EXAMPLE')
- expect(looksLikeSecret(secret)).not.toBeNull()
-
- wipe(secret)
-
- expect(secret.every((byte) => byte === 0)).toBe(true)
- })
+ it('is the only thing that raises a prompt', () => {
+ const result = classify({
+ formats: ['ExcludeClipboardContentFromMonitorProcessing'],
+ canIncludeInClipboardHistory: 0
+ })
- it('does not mind being handed nothing', () => {
- expect(() => wipe(null)).not.toThrow()
+ expect(result?.rule).toMatch(/concealed/)
})
})
-describe('the path exclusion stays narrow', () => {
- it('still flags a secret that merely contains slashes', () => {
- // The AWS secret key shape: slashes throughout, but not a path.
- expect(looksLikeSecret(bytes('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'))?.tier).toBe(2)
+// The heuristics that used to live here are gone. They guessed from content — PEM blocks, JWTs,
+// key prefixes, connection-string keywords, high-entropy strings — and prompted on a match. They
+// interrupted an ordinary workflow to report something the user already knew, and cost 147ms per
+// MiB because every needle walked the whole buffer. Nothing Spool holds leaves the machine, so the
+// guessing bought nothing it was worth paying for.
+describe('nothing is guessed from content (PLAN.md 4)', () => {
+ it('keeps a credential without asking, because copying one is an ordinary thing to do', () => {
+ // Content is not even passed in any more, which is the strongest form this claim can take.
+ expect(classify({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: null })).toBeNull()
+ expect(classify({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 1 })).toBeNull()
})
- it.each([
- ['a Windows path', 'C:/Users/wkoth/source/repos/Spool/src/main/detect'],
- ['a backslash path', 'C:\\Users\\wkoth\\AppData\\Local\\Programs\\Spool'],
- ['a POSIX path', '/usr/local/share/SpoolThings/Config'],
- ['a UNC path', '\\\\fileserver\\Shared\\Reports\\Q3Summary']
- ])('leaves %s alone', (_name, content) => {
- expect(looksLikeSecret(bytes(content))).toBeNull()
+ it('still asks when the application itself declared the copy concealed', () => {
+ const declared = { formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 }
+ expect(classify(declared)?.rule).toMatch(/clipboard history/)
})
})
diff --git a/src/main/detect/sensitivity.ts b/src/main/detect/sensitivity.ts
index a8b386c..0f8b937 100644
--- a/src/main/detect/sensitivity.ts
+++ b/src/main/detect/sensitivity.ts
@@ -1,27 +1,21 @@
-import {
- ascii,
- characterClasses,
- hasWhitespace,
- includes,
- indexOf,
- isDigit,
- shannonEntropy,
- startsWith,
- trim
-} from './bytes'
-
/**
- * The two tiers of PLAN.md 4, over bytes rather than strings.
+ * What the source application declared about a copy (PLAN.md 4), read from the clipboard formats
+ * rather than from the content.
+ *
+ * **Guessing from content was removed.** Spool used to also scan for PEM blocks, JWTs, key
+ * prefixes, connection-string keywords and high-entropy strings, and prompt when one matched. It
+ * interrupted an ordinary workflow — copying a credential is a normal thing to do, and nothing here
+ * leaves the machine — to say something the user already knew. It was also the entire cost of
+ * capture: 147ms per MiB, because each needle walked the whole buffer separately.
*
- * Different confidence, different wording: Tier 1 is what the source application *declared*, and is
- * authoritative. Tier 2 is a guess from shape, and says so. False positives are acceptable here;
- * silent capture of a secret is not.
+ * What is kept is not a guess. `CanIncludeInClipboardHistory = 0` is an explicit statement from the
+ * application that owns the secret, saying *do not persist this*; Windows' own Clipboard History
+ * obeys it. Spool makes a transient thing durable, so ignoring that request would persist exactly
+ * what a password manager asked it not to, and behave worse than the OS feature beside it. It costs
+ * a flag check.
*/
-export type Tier = 1 | 2
-
export interface Sensitivity {
- readonly tier: Tier
/** Which rule matched, for the privacy panel and for the prompt's second line. */
readonly rule: string
}
@@ -44,139 +38,22 @@ const DECLARED_FORMATS = new Set([
'org.nspasteboard.ConcealedType'
])
-/** Tier 1 — the source application marked this as secret. Password managers do this. */
+/** The source application marked this as secret. Password managers do this. */
export function declaredConcealed(signals: ConcealmentSignals): Sensitivity | null {
if (signals.formats.some((format) => DECLARED_FORMATS.has(format))) {
- return { tier: 1, rule: 'the application marked it as concealed' }
+ return { rule: 'the application marked it as concealed' }
}
if (signals.canIncludeInClipboardHistory === 0) {
- return { tier: 1, rule: 'the application asked to be kept out of clipboard history' }
+ return { rule: 'the application asked to be kept out of clipboard history' }
}
return null
}
-const PEM = ascii('-----BEGIN')
-const JWT = ascii('eyJ')
-const DOT = ascii('.')
-
-/** Key prefixes worth recognising by name (PLAN.md 4). */
-const KEY_PREFIXES: ReadonlyArray<[label: string, prefix: Uint8Array]> = [
- ['an OpenAI-style key (sk-)', ascii('sk-')],
- ['an AWS access key (AKIA)', ascii('AKIA')],
- ['a GitHub token (ghp_)', ascii('ghp_')],
- ['a GitHub token (github_pat_)', ascii('github_pat_')],
- ['a Slack token (xoxb-)', ascii('xoxb-')],
- ['a Google API key (AIza)', ascii('AIza')]
-]
-
-const CONNECTION_KEYWORDS: ReadonlyArray<[label: string, needle: Uint8Array]> = [
- ['a connection string (Password=)', ascii('Password=')],
- ['a connection string (pwd=)', ascii('pwd=')],
- ['a connection string (Server=)', ascii('Server=')]
-]
-
-/** Entropy high enough to look generated rather than written. */
-const ENTROPY_THRESHOLD = 4.0
-const ENTROPY_MIN_LENGTH = 16
-const ENTROPY_MAX_LENGTH = 200
-const ENTROPY_MIN_CLASSES = 3
-
-/** A URL is not a secret, and its punctuation would otherwise score like one. */
-const URL_MARKERS = [ascii('://'), ascii('www.')]
-
/**
- * Nor is an absolute file path, which a developer copies many times a day — and a prompt that fires
- * on every one of those teaches the user to dismiss prompts, which costs more than it saves.
+ * The whole classification. One question now: did the application say so?
*
- * Deliberately narrow: it recognises only what a path *starts* with. Checking for slashes anywhere
- * would be a hole, because an AWS secret key is full of them.
+ * It no longer takes the content, which is the point. Nothing here reads what you copied.
*/
-function looksLikeAbsolutePath(content: Uint8Array): boolean {
- const [first, second, third] = content
- const isSlash = (byte: number | undefined): boolean => byte === 0x2f || byte === 0x5c
- const isLetter =
- first !== undefined &&
- ((first >= 0x41 && first <= 0x5a) || (first >= 0x61 && first <= 0x7a))
-
- // C:/… or C:\…
- if (isLetter && second === 0x3a && isSlash(third)) return true
- // /usr/… or \\server\…
- return isSlash(first)
+export function classify(signals: ConcealmentSignals): Sensitivity | null {
+ return declaredConcealed(signals)
}
-
-/** Tier 2 — pattern or entropy match. Lower confidence, softer wording. */
-export function looksLikeSecret(bytes: Uint8Array): Sensitivity | null {
- const content = trim(bytes)
- if (content.length === 0) return null
-
- if (startsWith(content, PEM)) return { tier: 2, rule: 'a PEM block' }
- if (isJwt(content)) return { tier: 2, rule: 'a JWT' }
-
- for (const [label, prefix] of KEY_PREFIXES) {
- if (includes(content, prefix)) return { tier: 2, rule: label }
- }
-
- for (const [label, needle] of CONNECTION_KEYWORDS) {
- if (includes(content, needle, true)) return { tier: 2, rule: label }
- }
-
- if (isHighEntropy(content)) return { tier: 2, rule: 'a long random-looking string' }
-
- return null
-}
-
-/** `eyJ` followed by two dot-separated base64url segments. */
-function isJwt(content: Uint8Array): boolean {
- if (!startsWith(content, JWT)) return false
-
- const firstDot = indexOf(content, DOT)
- if (firstDot <= 0) return false
- const secondDot = indexOf(content, DOT, false, firstDot + 1)
- if (secondDot <= firstDot + 1) return false
-
- // Three segments, all base64url. The third may be empty for an unsigned token.
- return (
- isBase64Url(content.subarray(0, firstDot)) &&
- isBase64Url(content.subarray(firstDot + 1, secondDot)) &&
- isBase64Url(content.subarray(secondDot + 1))
- )
-}
-
-function isBase64Url(segment: Uint8Array): boolean {
- for (const byte of segment) {
- const alphanumeric =
- isDigit(byte) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a)
- if (!alphanumeric && byte !== 0x2d && byte !== 0x5f && byte !== 0x3d) return false
- }
- return true
-}
-
-function isHighEntropy(content: Uint8Array): boolean {
- if (content.length < ENTROPY_MIN_LENGTH || content.length > ENTROPY_MAX_LENGTH) return false
- if (hasWhitespace(content)) return false
- if (URL_MARKERS.some((marker) => includes(content, marker, true))) return false
- if (looksLikeAbsolutePath(content)) return false
- if (characterClasses(content) < ENTROPY_MIN_CLASSES) return false
-
- return shannonEntropy(content) >= ENTROPY_THRESHOLD
-}
-
-/**
- * The whole classification, in the order of PLAN.md 4: what the application declared beats what the
- * content looks like, because one is a statement and the other is a guess.
- */
-export function classify(signals: ConcealmentSignals, bytes: Uint8Array): Sensitivity | null {
- return declaredConcealed(signals) ?? looksLikeSecret(bytes)
-}
-
-/** Every Tier 2 rule, for the privacy panel — the user is owed the list of what trips a prompt. */
-export const HEURISTIC_RULES: ReadonlyArray<{ label: string; detail: string }> = [
- { label: 'PEM blocks', detail: 'text beginning -----BEGIN' },
- { label: 'JWTs', detail: 'eyJ followed by two dot-separated base64url segments' },
- { label: 'Known key prefixes', detail: 'sk-, AKIA, ghp_, github_pat_, xoxb-, AIza' },
- { label: 'Connection strings', detail: 'Password=, pwd=, Server=' },
- {
- label: 'High-entropy strings',
- detail: `${ENTROPY_MIN_LENGTH}–${ENTROPY_MAX_LENGTH} characters, no spaces, at least ${ENTROPY_MIN_CLASSES} character classes, and random-looking`
- }
-]
diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts
index 6058829..e62fbb0 100644
--- a/src/main/ipc/index.ts
+++ b/src/main/ipc/index.ts
@@ -85,6 +85,10 @@ export function registerIpc(
ipcMain.handle(CHANNELS.acknowledgePrivacy, () => actions.acknowledgePrivacy())
ipcMain.handle(CHANNELS.toggleMode, () => session.toggleMode())
ipcMain.handle(CHANNELS.setAutoPaste, (_event, enabled: boolean) => session.setAutoPaste(enabled))
+ ipcMain.handle(CHANNELS.toggleClipSelected, (_event, clipId: string) =>
+ session.toggleClipSelected(clipId)
+ )
+ ipcMain.handle(CHANNELS.selectAllClips, () => session.selectAllClips())
ipcMain.handle(CHANNELS.setHotkey, (_event, action: HotkeyAction, accelerator: string) =>
actions.setHotkey(action, accelerator)
)
@@ -129,6 +133,8 @@ export function registerIpc(
ipcMain.removeHandler(CHANNELS.acknowledgePrivacy)
ipcMain.removeHandler(CHANNELS.toggleMode)
ipcMain.removeHandler(CHANNELS.setAutoPaste)
+ ipcMain.removeHandler(CHANNELS.toggleClipSelected)
+ ipcMain.removeHandler(CHANNELS.selectAllClips)
ipcMain.removeHandler(CHANNELS.setHotkey)
ipcMain.removeHandler(CHANNELS.resetHotkey)
ipcMain.removeHandler(CHANNELS.resumeCapture)
diff --git a/src/main/ipc/view.ts b/src/main/ipc/view.ts
index 3a5751c..6d25f1b 100644
--- a/src/main/ipc/view.ts
+++ b/src/main/ipc/view.ts
@@ -1,3 +1,4 @@
+import { isSelected, selectedCount } from '../core/selection'
import { clipCap } from '../core/spool'
import type { Spool } from '../core/types'
import type { SpoolView } from '../../shared/ipc'
@@ -8,7 +9,10 @@ import type { SpoolView } from '../../shared/ipc'
* Clip **content** deliberately does not cross: the compact window shows previews, and the full
* text has no business in a renderer that only displays it. Pure, so it is tested without a window.
*/
-export function toSpoolView(spool: Spool): SpoolView {
+export function toSpoolView(
+ spool: Spool,
+ selection: ReadonlySet = new Set()
+): SpoolView {
return {
name: spool.name,
mode: spool.mode,
@@ -16,10 +20,17 @@ export function toSpoolView(spool: Spool): SpoolView {
id: clip.id,
preview: clip.preview,
capturedAt: clip.capturedAt,
- sourceApp: clip.sourceApp
+ sourceApp: clip.sourceApp,
+ isSelected: isSelected(clip.id, selection)
})),
cursorClipId: spool.cursorClipId,
count: spool.clips.length,
- cap: clipCap(spool.kind)
+ cap: clipCap(spool.kind),
+ // What a serve or a whole-spool paste would act on. Equal to `count` when nothing is chosen,
+ // because an empty selection means every clip.
+ inPlay: selectedCount(spool.clips, selection),
+ // Whether the user has actually chosen a subset, which is what the UI needs to know to offer
+ // a way back to all of them.
+ hasSelection: selection.size > 0
}
}
diff --git a/src/main/session.test.ts b/src/main/session.test.ts
index 465cbdb..1ef67e0 100644
--- a/src/main/session.test.ts
+++ b/src/main/session.test.ts
@@ -402,24 +402,23 @@ describe('consent (PLAN.md 4)', () => {
sourceApp: 'Code.exe'
})
- it('raises a Tier 1 prompt naming the application, and files nothing yet', () => {
+ it('raises a prompt naming the application, and files nothing yet', () => {
const { session, watcher } = started()
watcher.change(secret('hunter2'))
const { prompt, spool } = session.getState()
- expect(prompt?.tier).toBe(1)
expect(prompt?.headline).toBe('1Password marked this as concealed. Keep it in this spool?')
expect(spool.count).toBe(0)
})
- it('raises a softer Tier 2 prompt for something that merely looks like a secret', () => {
+ // The heuristics are gone: copying a credential is an ordinary thing to do, and nothing Spool
+ // holds leaves the machine, so guessing at content bought nothing worth its interruption.
+ it('does not ask about something that merely looks like a secret', () => {
const { session, watcher } = started()
watcher.change(heuristic('AKIAIOSFODNN7EXAMPLE'))
- const { prompt } = session.getState()
- expect(prompt?.tier).toBe(2)
- expect(prompt?.headline).toBe('This looks like a secret. Keep it in this spool?')
- expect(prompt?.detail).toMatch(/AWS/)
+ expect(session.getState().prompt).toBeNull()
+ expect(session.getState().spool.count).toBe(1)
})
it('never shows the content of the clip it is asking about', () => {
@@ -499,10 +498,10 @@ describe('consent (PLAN.md 4)', () => {
watcher.change(secret('from the manager'))
session.answerConsent('always_skip')
- watcher.change(heuristic('AKIAIOSFODNN7EXAMPLE'))
+ // A different application that also declares its copy concealed is still asked about.
+ watcher.change(secret('from somewhere else', 'Bitwarden.exe'))
- // A different application still gets asked about.
- expect(session.getState().prompt?.tier).toBe(2)
+ expect(session.getState().prompt?.headline).toMatch(/Bitwarden/)
})
it('an ordinary copy is never asked about', () => {
@@ -780,6 +779,115 @@ describe('pasting the whole spool (PLAN.md 3)', () => {
})
})
+describe('choosing which clips are in play (PLAN.md 3)', () => {
+ function withClips(...contents: string[]) {
+ const { session, watcher, written } = started((report) => report(true))
+ for (const content of contents) watcher.change(text(content))
+ return { session, watcher, written }
+ }
+
+ const ids = (session: Session): string[] => session.getState().spool.clips.map((c) => c.id)
+
+ it('unspools every clip when nothing has been chosen', () => {
+ const { session, written } = withClips('one', 'two', 'three')
+
+ session.serveNext()
+ session.serveNext()
+
+ expect(written).toEqual(['one', 'two'])
+ expect(session.getState().spool.inPlay).toBe(3)
+ expect(session.getState().spool.hasSelection).toBe(false)
+ })
+
+ it('steps over the clips that were not chosen', () => {
+ const { session, written } = withClips('one', 'two', 'three')
+ const [first, , third] = ids(session)
+ session.toggleClipSelected(first)
+ session.toggleClipSelected(third)
+
+ session.serveNext()
+ session.serveNext()
+
+ expect(written).toEqual(['one', 'three'])
+ })
+
+ it('wraps among the chosen clips rather than through the others', () => {
+ const { session, written } = withClips('one', 'two', 'three')
+ const [first, , third] = ids(session)
+ session.toggleClipSelected(first)
+ session.toggleClipSelected(third)
+
+ session.serveNext()
+ session.serveNext()
+ session.serveNext()
+
+ expect(written).toEqual(['one', 'three', 'one'])
+ })
+
+ it('joins only the chosen clips, in spool order', () => {
+ const { session, written } = withClips('one', 'two', 'three')
+ const [first, , third] = ids(session)
+ session.toggleClipSelected(third)
+ session.toggleClipSelected(first)
+
+ session.pasteWholeSpool()
+
+ expect(written).toEqual(['one\nthree'])
+ expect(session.getState().spool.inPlay).toBe(2)
+ expect(session.getState().spool.hasSelection).toBe(true)
+ })
+
+ it('serves a chosen clip even when the cursor sat on one that was not', () => {
+ const { session, written } = withClips('one', 'two', 'three')
+ // The cursor is on 'one'; choosing only the third must not leave serving stuck.
+ session.toggleClipSelected(ids(session)[2])
+
+ session.serveNext()
+
+ expect(written).toEqual(['three'])
+ })
+
+ it('unticking the last clip returns to all, rather than to none', () => {
+ const { session, written } = withClips('one', 'two')
+ const [first] = ids(session)
+ session.toggleClipSelected(first)
+ expect(session.getState().spool.inPlay).toBe(1)
+
+ session.toggleClipSelected(first)
+
+ expect(session.getState().spool.hasSelection).toBe(false)
+ session.pasteWholeSpool()
+ expect(written).toEqual(['one\ntwo'])
+ })
+
+ it('selecting all again is the way back, and costs nothing when already there', () => {
+ const { session } = withClips('one', 'two')
+ session.toggleClipSelected(ids(session)[0])
+
+ session.selectAllClips()
+
+ expect(session.getState().spool.hasSelection).toBe(false)
+ expect(session.getState().spool.inPlay).toBe(2)
+ })
+
+ it('forgets the choice when the clips it named are deleted', () => {
+ const { session } = withClips('one', 'two')
+ const [first] = ids(session)
+ session.toggleClipSelected(first)
+
+ session.deleteClip(first)
+
+ // The chosen clip is gone, so the set is empty — which means all of what is left.
+ expect(session.getState().spool.hasSelection).toBe(false)
+ expect(session.getState().spool.inPlay).toBe(1)
+ })
+
+ it('marks every clip as in play in the view when nothing is chosen', () => {
+ const { session } = withClips('one', 'two')
+ expect(session.getState().spool.clips.every((c) => c.isSelected)).toBe(true)
+ })
+})
+
describe('arranging (PLAN.md 11, M7)', () => {
it('applies an arrangement to the active spool', () => {
const { session, watcher } = started()
diff --git a/src/main/session.ts b/src/main/session.ts
index e34883a..1f0080d 100644
--- a/src/main/session.ts
+++ b/src/main/session.ts
@@ -35,6 +35,7 @@ import {
type MeasureName
} from './core/capacity'
import { expireClips, isRetentionHours } from './core/retention'
+import { prune, toggle } from './core/selection'
import { arrange, clear, createSpool, deleteClip, serve, setMode } from './core/spool'
import type { Clip, Mode, Spool } from './core/types'
import type { ClipboardSnapshot } from './detect/admit'
@@ -46,7 +47,6 @@ import {
ruleFromChoice
} from './detect/consent'
import { emptyLedger, NOTHING_TO_PASTE } from './detect/notices'
-import { HEURISTIC_RULES } from './detect/sensitivity'
import { toSpoolView } from './ipc/view'
import type { Store } from './store'
@@ -147,6 +147,16 @@ export class Session {
*/
private autoPaste = true
+ /**
+ * Which clips are in play (PLAN.md 3). Empty means every clip, and is the state the app starts
+ * in — a selection nobody has made is not a selection of nothing.
+ *
+ * Not stored, and cleared when the active spool changes. It describes what you are doing now, the
+ * way a text selection does; one that survived a restart would be a rule the user does not
+ * remember making.
+ */
+ private selection: ReadonlySet = new Set()
+
constructor(
private readonly writeText: (text: string) => void,
/**
@@ -176,6 +186,22 @@ export class Session {
this.publish()
}
+ /** Put one clip in or out of the working set. */
+ toggleClipSelected(clipId: string): void {
+ this.selection = toggle(this.selection, clipId)
+ this.publish()
+ }
+
+ /**
+ * Back to every clip. Clearing the selection and selecting everything are the same act, so there
+ * is one command rather than two that quietly disagree at the edges.
+ */
+ selectAllClips(): void {
+ if (this.selection.size === 0) return
+ this.selection = new Set()
+ this.publish()
+ }
+
setAutoPaste(enabled: boolean): void {
this.autoPaste = enabled
this.publish()
@@ -370,7 +396,7 @@ export class Session {
* pasted as many times as the user likes.
*/
serveNext(): void {
- const result = serve(this.state.spool)
+ const result = serve(this.state.spool, this.selection)
if (!result.ok) {
this.notice = NOTHING_TO_PASTE
@@ -406,7 +432,7 @@ export class Session {
* application on the machine.
*/
pasteWholeSpool(confirmed = false): void {
- const joined = joinSpool(this.state.spool, this.settings.separator)
+ const joined = joinSpool(this.state.spool, this.settings.separator, this.selection)
if (!joined.ok) {
this.notice = NOTHING_TO_PASTE
@@ -592,6 +618,9 @@ export class Session {
*/
deleteClip(clipId: string): void {
this.state = { ...this.state, spool: deleteClip(this.state.spool, clipId) }
+ // A selection holding a clip that is gone would keep counting it, so the button would promise
+ // more than it can deliver.
+ this.selection = prune(this.state.spool.clips, this.selection)
this.publish()
}
@@ -601,6 +630,8 @@ export class Session {
this.state = { ...this.state, spool: clear(this.state.spool) }
// The next copy is not a duplicate of something that is no longer there.
this.state = { ...this.state, lastCapturedText: null }
+ // Every clip the working set named has gone with them.
+ this.selection = new Set()
} else {
this.otherSpools = this.otherSpools.map((spool) =>
spool.id === spoolId ? clear(spool) : spool
@@ -834,6 +865,9 @@ export class Session {
const remaining = this.otherSpools.filter((spool) => spool.id !== next.id)
this.otherSpools = keepLeaving ? [...remaining, leaving] : remaining
+ // The working set named clips in the spool being left, so it ends with it. Carrying it across
+ // would leave a selection the user cannot see and did not make here.
+ this.selection = new Set()
// Duplicate suppression compares against the last capture *in this spool*, so it resets.
this.state = { ...this.state, spool: this.touch(next), lastCapturedText: null }
this.savedSpool = null
@@ -841,7 +875,7 @@ export class Session {
getState(): AppState {
return {
- spool: toSpoolView(this.state.spool),
+ spool: toSpoolView(this.state.spool, this.selection),
notice: this.notice,
capture: this.capture,
storage: this.storage,
@@ -866,7 +900,6 @@ export class Session {
autoPaste: this.autoPaste,
prompt: this.promptView(),
privacy: {
- heuristics: HEURISTIC_RULES,
consentTimeoutSeconds: Math.round(this.settings.consentTimeoutMs / 1000),
sourceRules: [...this.state.sourceRules].map(([sourceApp, action]) => ({
sourceApp,
@@ -918,7 +951,6 @@ export class Session {
const { headline, detail } = promptWording(this.pending.sensitivity, this.pending.sourceApp)
return {
- tier: this.pending.sensitivity.tier,
headline,
detail,
sourceApp: this.pending.sourceApp,
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 9c31d69..09d5dd1 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -71,6 +71,13 @@ const api = {
setAutoPaste: (enabled: boolean): Promise =>
ipcRenderer.invoke(CHANNELS.setAutoPaste, enabled),
+ /** Put one clip in or out of the working set (PLAN.md 3). */
+ toggleClipSelected: (clipId: string): Promise =>
+ ipcRenderer.invoke(CHANNELS.toggleClipSelected, clipId),
+
+ /** Back to every clip. Clearing a selection and selecting all are the same act. */
+ selectAllClips: (): Promise => ipcRenderer.invoke(CHANNELS.selectAllClips),
+
/** Change direction. On the mode pill rather than a hotkey (PLAN.md 8). */
toggleMode: (): Promise => ipcRenderer.invoke(CHANNELS.toggleMode),
diff --git a/src/renderer/components/ClipList.tsx b/src/renderer/components/ClipList.tsx
index ff2300a..fba77d5 100644
--- a/src/renderer/components/ClipList.tsx
+++ b/src/renderer/components/ClipList.tsx
@@ -38,6 +38,18 @@ export function ClipList({ spool }: { spool: SpoolView }): JSX.Element {
}
>
+ {/*
+ Checked means in play. With nothing chosen every box is checked, because an empty
+ selection means every clip — so the list never shows a state the hotkeys disagree
+ with (PLAN.md 3).
+ */}
+ void window.spool.toggleClipSelected(clip.id)}
+ aria-label={`Include ${clip.preview}`}
+ className="mt-0.5 shrink-0"
+ />
{source !== null && (
- {source}
+ {source}
)}
)
diff --git a/src/renderer/components/ConsentPrompt.tsx b/src/renderer/components/ConsentPrompt.tsx
index bcbf371..ca6a81c 100644
--- a/src/renderer/components/ConsentPrompt.tsx
+++ b/src/renderer/components/ConsentPrompt.tsx
@@ -16,16 +16,12 @@ export function ConsentPrompt({
prompt: PendingPrompt
onAnswer: (choice: ConsentChoice) => void
}): JSX.Element {
+ // One voice now: the only thing that raises this is the application saying so, so there is no
+ // softer styling for a guess.
const application = sourceName(prompt.sourceApp)
return (
-
+
{prompt.headline}
{prompt.detail}
diff --git a/src/renderer/components/ExpandedView.tsx b/src/renderer/components/ExpandedView.tsx
index a31770d..424f850 100644
--- a/src/renderer/components/ExpandedView.tsx
+++ b/src/renderer/components/ExpandedView.tsx
@@ -1,7 +1,7 @@
import { useState, type JSX } from 'react'
import type { AppState, SeparatorKind } from '../../shared/ipc'
import { hasChanged, sameClips } from '../helpers/ArrangeListHelper'
-import { formatBytes, separatorOptions } from '../helpers/ExpandedViewHelper'
+import { formatBytes, pasteAllLabel, separatorOptions } from '../helpers/ExpandedViewHelper'
import { ArrangeList } from './ArrangeList'
import { SpoolSidebar } from './SpoolSidebar'
@@ -134,10 +134,21 @@ export function ExpandedView({
onClick={() => void window.spool.pasteWholeSpool()}
className="w-full rounded border border-spool-thread/50 px-2 py-1.5 text-spool-thread hover:bg-spool-thread/10 disabled:border-spool-paper/10 disabled:text-spool-paper/25"
>
- Put all {spool.count} on the clipboard
+ {pasteAllLabel(spool)}
+ {spool.hasSelection && (
+
+ )}
- Then paste once, with Ctrl+V. The cursor does not move.
+ {spool.hasSelection
+ ? 'Ticked clips are the ones in play, for this button and for unspooling.'
+ : 'Then paste once, with Ctrl+V. The cursor does not move.'}
diff --git a/src/renderer/components/FirstRun.tsx b/src/renderer/components/FirstRun.tsx
index 26c65f4..5860284 100644
--- a/src/renderer/components/FirstRun.tsx
+++ b/src/renderer/components/FirstRun.tsx
@@ -39,9 +39,8 @@ export function FirstRun({
- Before anything that looks like a secret is stored, Spool asks. It looks for{' '}
- {privacy.heuristics.map((rule) => rule.label.toLowerCase()).join(', ')}, and it treats an
- application marking a copy as concealed — as password managers do — as authoritative. A
+ Spool does not inspect what you copy or guess whether it is a secret. When an application
+ marks a copy as concealed — as password managers do — Spool asks before keeping it, and a
prompt left unanswered for {privacy.consentTimeoutSeconds} seconds is treated as Skip.
diff --git a/src/renderer/components/PrivacyPanel.tsx b/src/renderer/components/PrivacyPanel.tsx
index 6690a6a..7259d14 100644
--- a/src/renderer/components/PrivacyPanel.tsx
+++ b/src/renderer/components/PrivacyPanel.tsx
@@ -49,21 +49,16 @@ export function PrivacyPanel({
-
+
- Spool asks before keeping anything that matches one of these. It never decides for you,
- and it never drops a clip on its own.
+ Spool does not read your clips looking for secrets, and does not guess. It once scanned
+ for key prefixes, connection strings and random-looking text; that was removed. Copying a
+ credential is an ordinary thing to do, and nothing here leaves this machine.
- An application can also mark a copy as concealed — password managers do — and that
- marking is treated as authoritative.
+ What remains is not a guess. An application can mark a copy as concealed — password
+ managers do, and Windows' own clipboard history obeys it — and that marking is treated as
+ authoritative.
diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts
index 4757508..3a639a1 100644
--- a/src/renderer/env.d.ts
+++ b/src/renderer/env.d.ts
@@ -27,6 +27,8 @@ declare global {
pauseCapture(): Promise
acknowledgePrivacy(): Promise
toggleMode(): Promise
+ toggleClipSelected(clipId: string): Promise
+ selectAllClips(): Promise
setAutoPaste(enabled: boolean): Promise
setHotkey(action: HotkeyAction, accelerator: string): Promise
resetHotkey(action: HotkeyAction): Promise
diff --git a/src/renderer/helpers/ArrangeListHelper.test.ts b/src/renderer/helpers/ArrangeListHelper.test.ts
index 1acb52d..d9b4f98 100644
--- a/src/renderer/helpers/ArrangeListHelper.test.ts
+++ b/src/renderer/helpers/ArrangeListHelper.test.ts
@@ -40,10 +40,10 @@ describe('hasChanged', () => {
describe('sourceLabel', () => {
it('drops the extension a Windows process name carries', () => {
- expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: 'EXCEL.EXE' })).toBe(
+ expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: 'EXCEL.EXE', isSelected: true })).toBe(
'EXCEL'
)
- expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: null })).toBeNull()
+ expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: null, isSelected: true })).toBeNull()
})
})
diff --git a/src/renderer/helpers/ClipListHelper.test.ts b/src/renderer/helpers/ClipListHelper.test.ts
index 5a07e9e..a283870 100644
--- a/src/renderer/helpers/ClipListHelper.test.ts
+++ b/src/renderer/helpers/ClipListHelper.test.ts
@@ -6,7 +6,8 @@ const clip = (id: string, sourceApp: string | null = null): ClipView => ({
id,
preview: `preview ${id}`,
capturedAt: '2026-08-22T07:00:00.000Z',
- sourceApp
+ sourceApp,
+ isSelected: true
})
const spool = (ids: string[], cursorClipId: string | null): SpoolView => ({
@@ -14,6 +15,8 @@ const spool = (ids: string[], cursorClipId: string | null): SpoolView => ({
mode: 'fifo',
clips: ids.map((id) => clip(id)),
cursorClipId,
+ inPlay: ids.length,
+ hasSelection: false,
count: ids.length,
cap: 50
})
diff --git a/src/renderer/helpers/ExpandedViewHelper.ts b/src/renderer/helpers/ExpandedViewHelper.ts
index 2a5558f..bee09a5 100644
--- a/src/renderer/helpers/ExpandedViewHelper.ts
+++ b/src/renderer/helpers/ExpandedViewHelper.ts
@@ -1,4 +1,4 @@
-import type { SeparatorKind } from '../../shared/ipc'
+import type { SeparatorKind, SpoolView } from '../../shared/ipc'
/** Pure helpers for the expanded window. No React, no I/O (PLAN.md 6). */
@@ -26,3 +26,16 @@ export function formatBytes(bytes: number): string {
if (kib >= 1) return `${Math.round(kib * 10) / 10} KB`
return `${bytes} bytes`
}
+
+/**
+ * What the whole-spool button says it will take (PLAN.md 3).
+ *
+ * It names the number in play rather than the number in the spool, because the button and the
+ * hotkey must agree: `Win+Alt+V` pastes exactly what this says. "All 15" when nothing is chosen,
+ * "3 of 15" when a subset is — the total stays visible so the selection is legible as a narrowing
+ * rather than as the whole truth.
+ */
+export function pasteAllLabel(spool: SpoolView): string {
+ if (!spool.hasSelection) return `Put all ${spool.count} on the clipboard`
+ return `Put ${spool.inPlay} of ${spool.count} on the clipboard`
+}
diff --git a/src/renderer/state/useAppState.ts b/src/renderer/state/useAppState.ts
index 9e07e4e..50b471b 100644
--- a/src/renderer/state/useAppState.ts
+++ b/src/renderer/state/useAppState.ts
@@ -8,12 +8,13 @@ import type { AppState } from '../../shared/ipc'
type Action = { type: 'state'; state: AppState }
const initialState: AppState = {
- spool: { name: 'Default spool', mode: 'fifo', clips: [], cursorClipId: null, count: 0, cap: 50 },
+ spool: { name: 'Default spool', mode: 'fifo', clips: [], cursorClipId: null,
+ inPlay: 0,
+ hasSelection: false, count: 0, cap: 50 },
notice: null,
capture: { available: false, reason: null },
prompt: null,
privacy: {
- heuristics: [],
consentTimeoutSeconds: 30,
dataFilePath: null,
sourceRules: [],
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts
index 1321425..341a9ae 100644
--- a/src/shared/ipc.ts
+++ b/src/shared/ipc.ts
@@ -53,6 +53,8 @@ export interface ClipView {
readonly preview: string
readonly capturedAt: string
readonly sourceApp: string | null
+ /** Whether this clip is in play. True for every clip when nothing has been chosen (PLAN.md 3). */
+ readonly isSelected: boolean
}
export interface SpoolView {
@@ -63,6 +65,10 @@ export interface SpoolView {
readonly cursorClipId: string | null
readonly count: number
readonly cap: number
+ /** How many clips a serve or a whole-spool paste would act on — `count` unless a subset is chosen. */
+ readonly inPlay: number
+ /** Whether the user has chosen a subset, as distinct from the empty selection that means all. */
+ readonly hasSelection: boolean
}
/** The four choices offered by the consent prompt (PLAN.md 4). */
@@ -70,8 +76,6 @@ export type ConsentChoice = 'keep_once' | 'skip' | 'always_keep' | 'always_skip'
/** A clip held in memory, unwritten, while the user decides (PLAN.md 4). */
export interface PendingPrompt {
- /** 1 is what the application declared and is authoritative; 2 is a guess from shape. */
- readonly tier: 1 | 2
readonly headline: string
readonly detail: string
/** Named so the standing-answer choices can say which application they apply to. */
@@ -82,7 +86,6 @@ export interface PendingPrompt {
/** What the privacy panel says Spool looks for, taken from the detectors themselves. */
export interface PrivacyFacts {
- readonly heuristics: ReadonlyArray<{ readonly label: string; readonly detail: string }>
readonly consentTimeoutSeconds: number
/** Where the encrypted store lives, or null while there is not one yet (M6). */
readonly dataFilePath: string | null
@@ -228,6 +231,8 @@ export const CHANNELS = {
toggleMode: 'spool:toggle-mode',
setHotkey: 'spool:set-hotkey',
setAutoPaste: 'spool:set-auto-paste',
+ toggleClipSelected: 'spool:toggle-clip-selected',
+ selectAllClips: 'spool:select-all-clips',
resetHotkey: 'spool:reset-hotkey',
resumeCapture: 'spool:resume-capture',
deleteSpools: 'spool:delete-spools',