diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c9fd632..6d3c71b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -134,10 +134,12 @@ jobs:
cd "$TAP_DIR"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- if git diff --quiet -- Casks/mnemo.rb; then
+ git add Casks/mnemo.rb
+ # Stage first, then check the staged diff: a brand-new (untracked) cask
+ # file is invisible to `git diff` but shows up in `git diff --cached`.
+ if git diff --cached --quiet; then
echo "Cask already up to date for ${VERSION}"
exit 0
fi
- git add Casks/mnemo.rb
git commit -m "mnemo ${TAG}"
git push
diff --git a/docs/superpowers/plans/2026-06-30-cut-split-tool.md b/docs/superpowers/plans/2026-06-30-cut-split-tool.md
new file mode 100644
index 0000000..68b3223
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-30-cut-split-tool.md
@@ -0,0 +1,995 @@
+# Cut / Split Tool Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a scissors tool to the markdown toolbar that snaps a guide line to block boundaries and offers Delete above / Delete below / Split at that point.
+
+**Architecture:** Extend the existing `rehypeSourcePositions` plugin to tag block elements with their source offset; a DOM helper turns those into snap boundaries; pure functions slice `content_md`; `splitChat` creates a second note from the part below. The toolbar gains a mutually-exclusive tool mode (`none`/`highlight`/`cut`).
+
+**Tech Stack:** React 19 + TypeScript, react-markdown@10 (remark-gfm, rehype-raw), Bun (`bun test`), Tauri, SQLite via `@tauri-apps/plugin-sql`.
+
+## Global Constraints
+
+- Block annotation attribute is exactly `data-md-block-start` (read via `el.dataset.mdBlockStart`); annotated block tags: `p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, table, hr`.
+- Cut units (snap points) are annotated blocks whose parent is the content container (top-level) OR a `UL`/`OL` (list items). The guide line appears only in gaps *between* consecutive cut units.
+- Boundary offset = the `data-md-block-start` of the block **below** the gap. Delete above = keep from offset; Delete below = keep up to offset; Split = above stays, below → new note.
+- Split-off note inherits source + folder + tags; title = first markdown heading in the part below, else `"
(2)"`; no AI re-analysis; user stays on the current note.
+- Delete shows a confirmation, then an Undo affordance for ~6s restoring the previous `content_md`.
+- Tool mode is mutually exclusive (`type ToolMode = "none" | "highlight" | "cut"`); `Esc` resets to `"none"`; switching chats resets to `"none"`.
+- `src/lib/highlight.ts` and `src/lib/cut.ts` must stay DOM-free (importable by `bun test`); DOM code lives in `src/lib/cut-dom.ts`.
+- Verify TypeScript with `bun run build` (NOT `npx tsc --noEmit`, which is a no-op here — `tsconfig.json` is a references stub). `*.test.ts` are excluded from the app build and run via `bun test`.
+- Commit messages: concise, no `Co-Authored-By` line.
+
+---
+
+### Task 1: Annotate block elements with source offsets (+ de-risk)
+
+**Files:**
+- Modify: `src/lib/highlight.ts` (the `HastElement` type and `walkHast`, ~lines 113-157)
+- Test: `src/lib/cut-plugin.test.ts`
+
+**Interfaces:**
+- Consumes: existing `rehypeSourcePositions` pipeline.
+- Produces: block elements (`p,h1-6,li,blockquote,pre,table,hr`) carry `data-md-block-start=""`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/lib/cut-plugin.test.ts`:
+```ts
+import { test, expect } from "bun:test";
+import { unified } from "unified";
+import remarkParse from "remark-parse";
+import remarkGfm from "remark-gfm";
+import remarkRehype from "remark-rehype";
+import rehypeRaw from "rehype-raw";
+import { rehypeSourcePositions } from "./highlight";
+
+function toHast(md: string) {
+ const p = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw);
+ return p.runSync(p.parse(md)) as any;
+}
+
+function collect(node: any, acc: any[] = []): any[] {
+ if (node.type === "element" && node.properties?.["data-md-block-start"] != null) acc.push(node);
+ for (const c of node.children ?? []) collect(c, acc);
+ return acc;
+}
+
+test("block elements get data-md-block-start pointing at the block's source start", () => {
+ const md = "## Heading\n\nA paragraph.\n\n- first item\n- second item\n";
+ const hast = toHast(md);
+ rehypeSourcePositions()(hast);
+ const blocks = collect(hast);
+ // tag -> source offset; verify the source at that offset begins the block.
+ const byTag: Record = {};
+ for (const b of blocks) byTag[b.tagName] ??= Number(b.properties["data-md-block-start"]);
+
+ expect(md.startsWith("## Heading", byTag["h2"])).toBe(true);
+ expect(md.startsWith("A paragraph.", byTag["p"])).toBe(true);
+ // first li starts at the "- first item" marker
+ const liStart = Number(blocks.find((b) => b.tagName === "li").properties["data-md-block-start"]);
+ expect(md.startsWith("- first item", liStart)).toBe(true);
+});
+
+test("each list item gets its own block-start at its marker", () => {
+ const md = "- alpha\n- beta\n- gamma\n";
+ const hast = toHast(md);
+ rehypeSourcePositions()(hast);
+ const lis = collect(hast).filter((b) => b.tagName === "li");
+ expect(lis.length).toBe(3);
+ const starts = lis.map((b) => Number(b.properties["data-md-block-start"])).sort((a, b) => a - b);
+ expect(md.startsWith("- alpha", starts[0])).toBe(true);
+ expect(md.startsWith("- beta", starts[1])).toBe(true);
+ expect(md.startsWith("- gamma", starts[2])).toBe(true);
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `bun test src/lib/cut-plugin.test.ts`
+Expected: FAIL (no `data-md-block-start` attributes yet). If instead it errors that block elements have no `position`, STOP — the de-risk failed and the approach needs revisiting; report back.
+
+- [ ] **Step 3: Add `position` to the `HastElement` type**
+
+In `src/lib/highlight.ts`, change the `HastElement` interface to include an optional position:
+```ts
+interface HastElement {
+ type: "element";
+ tagName: string;
+ properties?: Record;
+ children: HastChild[];
+ position?: { start?: { offset?: number } };
+}
+```
+
+- [ ] **Step 4: Annotate block elements in `walkHast`**
+
+In `src/lib/highlight.ts`, add the block-tag set next to `SKIP_TAGS`:
+```ts
+const BLOCK_TAGS = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre", "table", "hr"]);
+```
+Then, inside `walkHast`, in the `if (child.type === "element")` branch, annotate before recursing:
+```ts
+ if (child.type === "element") {
+ const el = child as HastElement;
+ if (BLOCK_TAGS.has(el.tagName)) {
+ const blockStart = el.position?.start?.offset;
+ if (blockStart != null) {
+ el.properties = el.properties ?? {};
+ el.properties["data-md-block-start"] = String(blockStart);
+ }
+ }
+ walkHast(el, inSkip || SKIP_TAGS.has(el.tagName));
+ } else if (child.type === "text" && !inSkip) {
+```
+(The text-node branch below is unchanged.)
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `bun test src/lib/cut-plugin.test.ts`
+Expected: PASS (2 tests). Then run the whole suite: `bun test` → all green (existing highlight tests unaffected).
+
+- [ ] **Step 6: Build**
+
+Run: `bun run build`
+Expected: `✓ built` with no errors.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/lib/highlight.ts src/lib/cut-plugin.test.ts
+git commit -m "feat: annotate block elements with source offsets for the cut tool"
+```
+
+---
+
+### Task 2: Pure markdown cut operations (`cut.ts`)
+
+**Files:**
+- Create: `src/lib/cut.ts`
+- Test: `src/lib/cut.test.ts`
+
+**Interfaces:**
+- Produces (used by Tasks 4, 7):
+ - `deleteAbove(md: string, offset: number): string`
+ - `deleteBelow(md: string, offset: number): string`
+ - `splitMarkdown(md: string, offset: number): { above: string; below: string }`
+ - `deriveSplitTitle(belowMd: string, originalTitle: string): string`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `src/lib/cut.test.ts`:
+```ts
+import { test, expect } from "bun:test";
+import { deleteAbove, deleteBelow, splitMarkdown, deriveSplitTitle } from "./cut";
+
+const md = "# A\n\nbody text\n\n## B\n\nmore text\n";
+const at = md.indexOf("## B");
+
+test("deleteAbove keeps from the offset, trimming leading whitespace", () => {
+ expect(deleteAbove(md, at)).toBe("## B\n\nmore text");
+});
+
+test("deleteBelow keeps up to the offset, trimming trailing whitespace", () => {
+ expect(deleteBelow(md, at)).toBe("# A\n\nbody text");
+});
+
+test("splitMarkdown returns trimmed above/below halves", () => {
+ expect(splitMarkdown(md, at)).toEqual({ above: "# A\n\nbody text", below: "## B\n\nmore text" });
+});
+
+test("deriveSplitTitle uses the first heading in the part below", () => {
+ expect(deriveSplitTitle("## Section two\n\ntext", "Orig")).toBe("Section two");
+});
+
+test("deriveSplitTitle finds a heading even if not on the first line", () => {
+ expect(deriveSplitTitle("intro line\n\n### Deep\n\nx", "Orig")).toBe("Deep");
+});
+
+test("deriveSplitTitle falls back to ' (2)' when there is no heading", () => {
+ expect(deriveSplitTitle("just text, no heading", "Orig")).toBe("Orig (2)");
+});
+
+test("deriveSplitTitle strips trailing closing hashes", () => {
+ expect(deriveSplitTitle("# Title #\n", "Orig")).toBe("Title");
+});
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `bun test src/lib/cut.test.ts`
+Expected: FAIL (module `./cut` not found).
+
+- [ ] **Step 3: Implement `cut.ts`**
+
+Create `src/lib/cut.ts`:
+```ts
+export function deleteAbove(md: string, offset: number): string {
+ return md.slice(offset).replace(/^\s+/, "");
+}
+
+export function deleteBelow(md: string, offset: number): string {
+ return md.slice(0, offset).replace(/\s+$/, "");
+}
+
+export function splitMarkdown(md: string, offset: number): { above: string; below: string } {
+ return {
+ above: md.slice(0, offset).replace(/\s+$/, ""),
+ below: md.slice(offset).replace(/^\s+/, ""),
+ };
+}
+
+export function deriveSplitTitle(belowMd: string, originalTitle: string): string {
+ const m = belowMd.match(/^#{1,6}\s+(.+?)\s*#*\s*$/m);
+ if (m) return m[1].trim();
+ return `${originalTitle} (2)`;
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `bun test src/lib/cut.test.ts`
+Expected: PASS (7 tests).
+
+- [ ] **Step 5: Build**
+
+Run: `bun run build`
+Expected: `✓ built`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/lib/cut.ts src/lib/cut.test.ts
+git commit -m "feat: pure markdown cut/split operations"
+```
+
+---
+
+### Task 3: Cut boundaries from the DOM (`cut-dom.ts`)
+
+**Files:**
+- Create: `src/lib/cut-dom.ts`
+- Test: `src/lib/cut-dom.test.ts`
+
+**Interfaces:**
+- Produces (used by Task 6):
+ - `interface CutBoundary { y: number; offset: number }`
+ - `getCutBoundaries(container: HTMLElement): CutBoundary[]`
+ - `nearestBoundary(boundaries: CutBoundary[], y: number): CutBoundary | null`
+
+- [ ] **Step 1: Write the failing test (pure part)**
+
+Create `src/lib/cut-dom.test.ts`:
+```ts
+import { test, expect } from "bun:test";
+import { nearestBoundary, type CutBoundary } from "./cut-dom";
+
+const bs: CutBoundary[] = [
+ { y: 10, offset: 5 },
+ { y: 50, offset: 20 },
+ { y: 200, offset: 99 },
+];
+
+test("nearestBoundary returns the closest boundary by y", () => {
+ expect(nearestBoundary(bs, 12)).toEqual({ y: 10, offset: 5 });
+ expect(nearestBoundary(bs, 40)).toEqual({ y: 50, offset: 20 });
+ expect(nearestBoundary(bs, 1000)).toEqual({ y: 200, offset: 99 });
+});
+
+test("nearestBoundary returns null for an empty list", () => {
+ expect(nearestBoundary([], 5)).toBeNull();
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `bun test src/lib/cut-dom.test.ts`
+Expected: FAIL (module `./cut-dom` not found).
+
+- [ ] **Step 3: Implement `cut-dom.ts`**
+
+Create `src/lib/cut-dom.ts`:
+```ts
+export interface CutBoundary {
+ y: number; // position in the container's scroll-content coordinate space
+ offset: number; // content_md offset of the block below the gap
+}
+
+// Pure: the boundary whose y is closest to the target y.
+export function nearestBoundary(boundaries: CutBoundary[], y: number): CutBoundary | null {
+ let best: CutBoundary | null = null;
+ let bestDist = Infinity;
+ for (const b of boundaries) {
+ const d = Math.abs(b.y - y);
+ if (d < bestDist) {
+ bestDist = d;
+ best = b;
+ }
+ }
+ return best;
+}
+
+// DOM: gaps between consecutive cut units (top-level blocks and list items).
+export function getCutBoundaries(container: HTMLElement): CutBoundary[] {
+ const isCutUnit = (el: HTMLElement): boolean => {
+ const p = el.parentElement;
+ return p === container || (!!p && (p.tagName === "UL" || p.tagName === "OL"));
+ };
+ const containerTop = container.getBoundingClientRect().top;
+ const scrollTop = container.scrollTop;
+ const items = Array.from(container.querySelectorAll("[data-md-block-start]"))
+ .filter(isCutUnit)
+ .map((el) => {
+ const r = el.getBoundingClientRect();
+ return {
+ top: r.top - containerTop + scrollTop,
+ bottom: r.bottom - containerTop + scrollTop,
+ offset: Number(el.dataset.mdBlockStart),
+ };
+ })
+ .filter((it) => !Number.isNaN(it.offset))
+ .sort((a, b) => a.top - b.top);
+
+ const boundaries: CutBoundary[] = [];
+ for (let i = 0; i < items.length - 1; i++) {
+ boundaries.push({ y: (items[i].bottom + items[i + 1].top) / 2, offset: items[i + 1].offset });
+ }
+ return boundaries;
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `bun test src/lib/cut-dom.test.ts`
+Expected: PASS (2 tests). `getCutBoundaries` is DOM-dependent and is verified manually in Task 6.
+
+- [ ] **Step 5: Build**
+
+Run: `bun run build`
+Expected: `✓ built`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/lib/cut-dom.ts src/lib/cut-dom.test.ts
+git commit -m "feat: compute cut boundaries from rendered blocks"
+```
+
+---
+
+### Task 4: `splitChat` in useDatabase + thread to ChatDetail
+
+**Files:**
+- Modify: `src/hooks/useDatabase.ts` (add `splitChat`, export it in the return object ~line 432)
+- Modify: `src/App.tsx` (destructure `splitChat` ~line 59; pass `onSplitChat` and `onOpenChat` to `` ~line 482)
+- Modify: `src/components/ChatDetail/ChatDetail.tsx` (extend `ChatDetailProps`)
+
+**Interfaces:**
+- Consumes: `splitMarkdown`, `deriveSplitTitle` from `../lib/cut`; `db.insertChat`, `db.getTagsForChat`, `db.addTagToChat`, `db.updateChat`, `db.getAllChats`.
+- Produces: `splitChat(chatId: string, offset: number): Promise`; `ChatDetailProps.onSplitChat` and `ChatDetailProps.onOpenChat`.
+
+- [ ] **Step 1: Import the pure ops in useDatabase**
+
+In `src/hooks/useDatabase.ts`, add near the other imports:
+```ts
+import { splitMarkdown, deriveSplitTitle } from "../lib/cut";
+```
+
+- [ ] **Step 2: Implement `splitChat`**
+
+In `src/hooks/useDatabase.ts`, add this callback alongside the other `useCallback`s (e.g., just after `importFile`):
+```ts
+ const splitChat = useCallback(async (chatId: string, offset: number): Promise => {
+ const all = await db.getAllChats();
+ const chat = all.find((c) => c.id === chatId);
+ if (!chat) return null;
+ const { above, below } = splitMarkdown(chat.content_md, offset);
+ const newChat = await db.insertChat({
+ title: deriveSplitTitle(below, chat.title),
+ summary: null,
+ source: chat.source,
+ content_md: below,
+ content_html: null,
+ imported_at: new Date().toISOString(),
+ chat_date: chat.chat_date,
+ folder_id: chat.folder_id,
+ deleted_at: null,
+ favorite: 0,
+ });
+ const tags = await db.getTagsForChat(chatId);
+ for (const t of tags) await db.addTagToChat(newChat.id, t.id);
+ await db.updateChat(chatId, { content_md: above });
+ await refreshChats();
+ await refreshTags();
+ await refreshFolders();
+ const updatedCurrent = (await db.getAllChats()).find((c) => c.id === chatId);
+ if (updatedCurrent) setSelectedChat(updatedCurrent);
+ return newChat;
+ }, [refreshChats, refreshTags, refreshFolders, setSelectedChat]);
+```
+
+- [ ] **Step 3: Export `splitChat` from the hook**
+
+In the `return { ... }` object of `useDatabase` (~line 432), add `splitChat,` (e.g., right after `importFile,`).
+
+- [ ] **Step 4: Extend `ChatDetailProps`**
+
+In `src/components/ChatDetail/ChatDetail.tsx`, add to the `ChatDetailProps` interface:
+```ts
+ onSplitChat: (chatId: string, offset: number) => Promise;
+ onOpenChat: (chatId: string) => void;
+```
+(`Chat` is already imported at the top of the file.)
+
+- [ ] **Step 5: Wire props in App**
+
+In `src/App.tsx`, add `splitChat` to the `useDatabase()` destructure (~line 59, near `importFile, updateChat`). Then in the `` JSX (~line 482), add:
+```tsx
+ onSplitChat={splitChat}
+ onOpenChat={(id) => {
+ const c = chats.find((x) => x.id === id);
+ if (c) setSelectedChat(c);
+ }}
+```
+(`chats` and `setSelectedChat` are already destructured from `useDatabase()`.)
+
+- [ ] **Step 6: Build**
+
+Run: `bun run build`
+Expected: `✓ built` (props are now required; the single `` call site supplies them). Also run `bun test` → still green.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/hooks/useDatabase.ts src/App.tsx src/components/ChatDetail/ChatDetail.tsx
+git commit -m "feat: splitChat creates a second note from the part below"
+```
+
+---
+
+### Task 5: Tool mode refactor + scissors button
+
+**Files:**
+- Modify: `src/components/ChatDetail/MarkdownToolbar.tsx` (two tools)
+- Modify: `src/components/ChatDetail/ChatDetail.tsx` (`armed` → `tool` mode)
+- Modify: `src/index.css` (crosshair for cut mode)
+
+**Interfaces:**
+- Produces: `export type ToolMode = "none" | "highlight" | "cut"` from `MarkdownToolbar`; `ChatDetail` holds `tool` state used by Task 6.
+
+- [ ] **Step 1: Rewrite `MarkdownToolbar` with two tools**
+
+Replace the whole of `src/components/ChatDetail/MarkdownToolbar.tsx` with:
+```tsx
+export type ToolMode = "none" | "highlight" | "cut";
+
+interface MarkdownToolbarProps {
+ tool: ToolMode;
+ notice: string | null;
+ onToggleHighlight: () => void;
+ onToggleCut: () => void;
+}
+
+export default function MarkdownToolbar({ tool, notice, onToggleHighlight, onToggleCut }: MarkdownToolbarProps) {
+ return (
+
+
+
+ {tool === "highlight" && Drag to highlight · Esc to stop}
+ {tool === "cut" && Click between blocks · Esc to stop}
+ {notice && {notice}}
+
+ );
+}
+```
+
+- [ ] **Step 2: Refactor `ChatDetail` state from `armed` to `tool`**
+
+In `src/components/ChatDetail/ChatDetail.tsx`:
+
+(a) Update the toolbar import to also pull the type:
+```tsx
+import MarkdownToolbar, { type ToolMode } from "./MarkdownToolbar";
+```
+
+(b) Replace the state declaration `const [armed, setArmed] = useState(false);` with:
+```tsx
+ const [tool, setTool] = useState("none");
+```
+
+(c) In the `[chat.id]` reset effect, replace `setArmed(false);` with:
+```tsx
+ setTool("none");
+```
+
+(d) Replace `handleToggleHighlighter` with two togglers:
+```tsx
+ const handleToggleHighlight = useCallback(() => {
+ setTool((t) => (t === "highlight" ? "none" : "highlight"));
+ }, []);
+ const handleToggleCut = useCallback(() => {
+ setTool((t) => (t === "cut" ? "none" : "cut"));
+ }, []);
+```
+
+(e) In the highlight mouseup effect, change the guard `if (!armed) return;` to `if (tool !== "highlight") return;`, change the apply call's color arg to keep `"yellow"`, and update its dependency array from `[armed, ...]` to `[tool, chat.id, chat.content_md, onUpdateChat, focusMode]`.
+
+(f) Replace the Esc effect with one that exits any tool:
+```tsx
+ useEffect(() => {
+ if (tool === "none") return;
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setTool("none"); };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [tool]);
+```
+
+(g) In BOTH content-container `className` template literals, replace `${armed ? " hl-armed" : ""}` with:
+```tsx
+${tool === "highlight" ? " hl-armed" : tool === "cut" ? " cut-armed" : ""}
+```
+
+(h) In BOTH `` usages, replace the props `armed={armed} ... onToggle={handleToggleHighlighter}` with:
+```tsx
+ tool={tool}
+ notice={hlNotice}
+ onToggleHighlight={handleToggleHighlight}
+ onToggleCut={handleToggleCut}
+```
+
+- [ ] **Step 3: Add crosshair CSS for cut mode**
+
+In `src/index.css`, just after the existing `.md-content.hl-armed` rule, add:
+```css
+.md-content.cut-armed,
+.md-content.cut-armed * {
+ cursor: crosshair;
+}
+```
+
+- [ ] **Step 4: Build and test**
+
+Run: `bun run build` → `✓ built`; `bun test` → all green.
+
+- [ ] **Step 5: Manual verification**
+
+`bun run tauri dev`: the highlighter still works exactly as before; a second (scissors) button appears; clicking it shows a crosshair over the content and the "Click between blocks · Esc to stop" hint; the two tools are mutually exclusive; `Esc` exits.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/ChatDetail/MarkdownToolbar.tsx src/components/ChatDetail/ChatDetail.tsx src/index.css
+git commit -m "refactor: mutually-exclusive tool mode + scissors button"
+```
+
+---
+
+### Task 6: CutOverlay — guide line + action menu
+
+**Files:**
+- Create: `src/components/ChatDetail/CutOverlay.tsx`
+- Modify: `src/components/ChatDetail/ChatDetail.tsx` (render `CutOverlay` when `tool === "cut"`, with stub handlers)
+- Modify: `src/index.css` (guide line + menu)
+
+**Interfaces:**
+- Consumes: `getCutBoundaries`, `nearestBoundary`, `type CutBoundary` from `../../lib/cut-dom`.
+- Produces: `CutOverlay` calling `onDeleteAbove(offset)`, `onDeleteBelow(offset)`, `onSplit(offset)`.
+
+- [ ] **Step 1: Create the `CutOverlay` component**
+
+Create `src/components/ChatDetail/CutOverlay.tsx`:
+```tsx
+import { useEffect, useRef, useState, useCallback } from "react";
+import { getCutBoundaries, nearestBoundary, type CutBoundary } from "../../lib/cut-dom";
+
+interface CutOverlayProps {
+ containerRef: React.RefObject;
+ contentMd: string;
+ onDeleteAbove: (offset: number) => void;
+ onDeleteBelow: (offset: number) => void;
+ onSplit: (offset: number) => void;
+}
+
+export default function CutOverlay({ containerRef, contentMd, onDeleteAbove, onDeleteBelow, onSplit }: CutOverlayProps) {
+ const boundaries = useRef([]);
+ const [lineY, setLineY] = useState(null);
+ const [menu, setMenu] = useState<{ x: number; y: number; offset: number } | null>(null);
+ const current = useRef(null);
+
+ const recompute = useCallback(() => {
+ const c = containerRef.current;
+ if (c) boundaries.current = getCutBoundaries(c);
+ }, [containerRef]);
+
+ // Recompute boundaries when the tool opens, the content changes, or layout shifts.
+ useEffect(() => {
+ recompute();
+ const c = containerRef.current;
+ if (!c) return;
+ c.addEventListener("scroll", recompute);
+ window.addEventListener("resize", recompute);
+ return () => {
+ c.removeEventListener("scroll", recompute);
+ window.removeEventListener("resize", recompute);
+ };
+ }, [recompute, contentMd]);
+
+ useEffect(() => {
+ const c = containerRef.current;
+ if (!c) return;
+ const onMove = (e: MouseEvent) => {
+ if (menu) return; // freeze the line while the menu is open
+ const rect = c.getBoundingClientRect();
+ const yInContent = e.clientY - rect.top + c.scrollTop;
+ const b = nearestBoundary(boundaries.current, yInContent);
+ current.current = b;
+ setLineY(b ? b.y : null);
+ };
+ const onClick = (e: MouseEvent) => {
+ if (!current.current) return;
+ e.preventDefault();
+ setMenu({ x: e.clientX, y: e.clientY, offset: current.current.offset });
+ };
+ c.addEventListener("mousemove", onMove);
+ c.addEventListener("click", onClick);
+ return () => {
+ c.removeEventListener("mousemove", onMove);
+ c.removeEventListener("click", onClick);
+ };
+ }, [containerRef, menu]);
+
+ // Close the menu on Escape or outside click.
+ useEffect(() => {
+ if (!menu) return;
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenu(null); };
+ const onDown = (e: MouseEvent) => {
+ if (!(e.target as HTMLElement)?.closest(".cut-menu")) setMenu(null);
+ };
+ window.addEventListener("keydown", onKey, true);
+ window.addEventListener("mousedown", onDown, true);
+ return () => {
+ window.removeEventListener("keydown", onKey, true);
+ window.removeEventListener("mousedown", onDown, true);
+ };
+ }, [menu]);
+
+ const act = (fn: (offset: number) => void) => {
+ if (menu) fn(menu.offset);
+ setMenu(null);
+ };
+
+ return (
+ <>
+ {lineY != null && }
+ {menu && (
+
+
+
+
+
+ )}
+ >
+ );
+}
+```
+
+- [ ] **Step 2: Render `CutOverlay` from ChatDetail with stub handlers**
+
+In `src/components/ChatDetail/ChatDetail.tsx`, add the import:
+```tsx
+import CutOverlay from "./CutOverlay";
+```
+Then, in BOTH content containers (focus and normal), immediately after the `` element, add:
+```tsx
+ {tool === "cut" && (
+ console.warn("deleteAbove", offset)}
+ onDeleteBelow={(offset) => console.warn("deleteBelow", offset)}
+ onSplit={(offset) => console.warn("split", offset)}
+ />
+ )}
+```
+(These stubs are replaced in Task 7.)
+
+- [ ] **Step 3: Make the content container a positioning context + style the line and menu**
+
+In `src/index.css`, add:
+```css
+/* Cut tool: the content container anchors the absolutely-positioned guide line */
+.md-content.cut-armed {
+ position: relative;
+ user-select: none;
+}
+.cut-line {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 0;
+ border-top: 2px dashed var(--red);
+ pointer-events: none;
+ z-index: 6;
+}
+.cut-menu {
+ position: fixed;
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+ min-width: 140px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-subtle);
+ border-radius: 8px;
+ padding: 4px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+}
+.cut-menu button {
+ text-align: left;
+ background: none;
+ border: none;
+ color: var(--text-primary);
+ padding: 7px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+}
+.cut-menu button:hover {
+ background: var(--bg-hover);
+}
+```
+
+- [ ] **Step 4: Build and test**
+
+Run: `bun run build` → `✓ built`; `bun test` → green.
+
+- [ ] **Step 5: Manual verification**
+
+`bun run tauri dev`: arm the scissors; moving the mouse over the content shows a red dashed line snapping to the gaps between paragraphs and between list items (and around a code block); clicking opens the 3-item menu at the cursor; choosing an item logs `deleteAbove/deleteBelow/split` with an offset to the console; `Esc`/outside-click closes the menu. (The actions are wired in Task 7.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/ChatDetail/CutOverlay.tsx src/components/ChatDetail/ChatDetail.tsx src/index.css
+git commit -m "feat: cut guide line and action menu overlay"
+```
+
+---
+
+### Task 7: Wire cut actions — delete (confirm + undo) and split (toast)
+
+**Files:**
+- Modify: `src/components/ChatDetail/ChatDetail.tsx` (real handlers, confirm modal, undo toast, split toast)
+- Modify: `src/index.css` (confirm modal reuse + toast)
+
+**Interfaces:**
+- Consumes: `deleteAbove`, `deleteBelow` from `../../lib/cut`; `onSplitChat`, `onOpenChat` (Task 4); `onUpdateChat`.
+
+- [ ] **Step 1: Import the pure delete ops**
+
+In `src/components/ChatDetail/ChatDetail.tsx`, add to the `../../lib/cut` usage (new import line):
+```tsx
+import { deleteAbove, deleteBelow } from "../../lib/cut";
+```
+
+- [ ] **Step 2: Add cut-action state**
+
+Near the other `useState`s in `ChatDetail`, add:
+```tsx
+ const [cutConfirm, setCutConfirm] = useState<{ direction: "above" | "below"; offset: number } | null>(null);
+ const [cutUndo, setCutUndo] = useState(null); // previous content_md
+ const [splitToast, setSplitToast] = useState(null); // new chat id
+ const cutUndoTimer = useRef | null>(null);
+ const splitToastTimer = useRef | null>(null);
+```
+In the `[chat.id]` reset effect, also add:
+```tsx
+ setCutConfirm(null);
+ setCutUndo(null);
+ setSplitToast(null);
+ if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; }
+ if (splitToastTimer.current) { clearTimeout(splitToastTimer.current); splitToastTimer.current = null; }
+```
+
+- [ ] **Step 3: Add the cut handlers**
+
+In `ChatDetail`, near the other cut-related callbacks, add:
+```tsx
+ const performDelete = useCallback((direction: "above" | "below", offset: number) => {
+ const prev = chat.content_md;
+ const next = direction === "above" ? deleteAbove(prev, offset) : deleteBelow(prev, offset);
+ onUpdateChat(chat.id, { content_md: next });
+ setCutUndo(prev);
+ if (cutUndoTimer.current) clearTimeout(cutUndoTimer.current);
+ cutUndoTimer.current = setTimeout(() => setCutUndo(null), 6000);
+ }, [chat.id, chat.content_md, onUpdateChat]);
+
+ const handleCutUndo = useCallback(() => {
+ if (cutUndo == null) return;
+ onUpdateChat(chat.id, { content_md: cutUndo });
+ setCutUndo(null);
+ if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; }
+ }, [cutUndo, chat.id, onUpdateChat]);
+
+ const handleSplit = useCallback(async (offset: number) => {
+ const newChat = await onSplitChat(chat.id, offset);
+ if (newChat) {
+ setSplitToast(newChat.id);
+ if (splitToastTimer.current) clearTimeout(splitToastTimer.current);
+ splitToastTimer.current = setTimeout(() => setSplitToast(null), 8000);
+ }
+ }, [chat.id, onSplitChat]);
+```
+
+- [ ] **Step 4: Connect the overlay handlers**
+
+In BOTH `` usages, replace the three stub props with:
+```tsx
+ onDeleteAbove={(offset) => setCutConfirm({ direction: "above", offset })}
+ onDeleteBelow={(offset) => setCutConfirm({ direction: "below", offset })}
+ onSplit={(offset) => handleSplit(offset)}
+```
+
+- [ ] **Step 5: Render confirm modal + toasts (shared by both views)**
+
+The scissors tool is available in both the focus and normal views, so the modals must render in both. To avoid duplicating JSX, define them once as a const BEFORE the `if (focusMode) { return ... }` early return in `ChatDetail`:
+```tsx
+ const cutModals = (
+ <>
+ {cutConfirm && (
+
setCutConfirm(null)}>
+
e.stopPropagation()}>
+
+ Delete the part {cutConfirm.direction} this line?
+
+
+
+
+
+
+
+ )}
+ {cutUndo != null && (
+
+ Content deleted
+
+
+ )}
+ {splitToast && (
+
+ Note split
+
+
+ )}
+ >
+ );
+```
+Then include `{cutModals}` once in the focus return (just before its root `` closes) and once in the normal return (next to the other modals like `mdPreview`). Two insertion points, the same `{cutModals}` expression in each — do not re-inline the JSX.
+
+- [ ] **Step 6: Style the confirm modal and toast**
+
+In `src/index.css`, add:
+```css
+.cut-confirm {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-subtle);
+ border-radius: 10px;
+ padding: 18px;
+ max-width: 320px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+}
+.cut-confirm-text {
+ color: var(--text-primary);
+ font-size: 14px;
+ margin-bottom: 14px;
+}
+.cut-confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+.cut-confirm-actions button {
+ border-radius: 6px;
+ padding: 6px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ border: 1px solid var(--border-subtle);
+}
+.cut-confirm-cancel { background: none; color: var(--text-muted); }
+.cut-confirm-delete { background: var(--red); border-color: var(--red); color: #fff; }
+.cut-toast {
+ position: fixed;
+ bottom: 20px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 60;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border-subtle);
+ border-radius: 8px;
+ padding: 10px 14px;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
+ font-size: 13px;
+ color: var(--text-primary);
+}
+.cut-toast button {
+ background: none;
+ border: none;
+ color: var(--accent, #2d6eb4);
+ font-weight: 600;
+ cursor: pointer;
+}
+```
+
+- [ ] **Step 7: Build and test**
+
+Run: `bun run build` → `✓ built`; `bun test` → green.
+
+- [ ] **Step 8: Manual verification**
+
+`bun run tauri dev`:
+1. Scissors → click between two blocks → menu → Delete above → confirm → top gone → Undo toast restores it within 6s.
+2. Delete below similarly.
+3. Split between two blocks → current note keeps the top; a new note exists with the bottom, same source/folder/tags, title from the bottom's first heading (or " (2)"); "Open new note" toast switches to it.
+4. Cutting between two list items splits the list cleanly; cutting around a code block works.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/components/ChatDetail/ChatDetail.tsx src/index.css
+git commit -m "feat: cut actions — delete with confirm+undo, split with toast"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Scissors tool + crosshair → Tasks 5, 6.
+- Guide line snapping between blocks incl. list items → Tasks 1, 3, 6.
+- Block source offsets via the plugin → Task 1.
+- Boundary offset = below block's start; delete above/below/split slicing → Tasks 2, 7.
+- Split inherits source+folder+tags, derived title, no AI, stay on current + toast → Tasks 2, 4, 7.
+- Delete confirm + undo (~6s) → Task 7.
+- Mutually-exclusive tool mode + Esc + reset on chat change → Task 5.
+- De-risk block offsets first → Task 1 (Step 2 gate).
+- DOM-free `cut.ts`/`highlight.ts`, DOM in `cut-dom.ts` → Tasks 1-3.
+- Re-index on edit handled by existing `insertChat`/`updateChat` → Task 4 (no extra work).
+- Verify with `bun run build` not `npx tsc --noEmit` → Global Constraints, every build step.
+
+**Placeholder scan:** None — every code step contains full code; manual-verification steps describe concrete observable outcomes.
+
+**Type consistency:** `CutBoundary`, `getCutBoundaries`, `nearestBoundary`, `deleteAbove`, `deleteBelow`, `splitMarkdown`, `deriveSplitTitle`, `splitChat`, `ToolMode`, `onSplitChat`, `onOpenChat`, and `data-md-block-start` are used with identical names/signatures across tasks.
+
+## Notes / Known limitations (from the spec)
+
+- No sub-block precision (can't cut inside a paragraph or code block).
+- Cutting inside a multi-paragraph blockquote is possible but rare; not specially handled.
+- Undo holds the previous `content_md` only until the 6s timer or a chat change.
+- Split has no undo (non-destructive; merge back manually).
diff --git a/docs/superpowers/specs/2026-06-30-cut-split-tool-design.md b/docs/superpowers/specs/2026-06-30-cut-split-tool-design.md
new file mode 100644
index 0000000..dd9f6e9
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-30-cut-split-tool-design.md
@@ -0,0 +1,157 @@
+# Cut / Split Tool — Design
+
+- **Date:** 2026-06-30
+- **Status:** Approved for spec review
+- **Component area:** `src/components/ChatDetail/`, `src/lib/`, `src/hooks/useDatabase.ts`, `src/index.css`
+
+## Summary
+
+Add a second tool to the markdown toolbar: a **scissors (cut) tool**. When active, the
+cursor is a crosshair and a horizontal guide line snaps to the gap between blocks
+nearest the mouse. Left-clicking the line opens a small menu with three actions:
+
+1. **Delete above** — remove all content above the line.
+2. **Delete below** — remove all content below the line.
+3. **Split** — split the note in two at the line: the current note keeps the part
+ above, a new note is created with the part below.
+
+The cut point maps to a precise offset in `content_md` via source positions exposed on
+block elements, so every action edits the markdown source cleanly at a block boundary.
+
+## User decisions (from brainstorming)
+
+1. **Granularity:** the line snaps to boundaries between blocks **including individual
+ list items** (`li`), not only top-level blocks. The line only appears in gaps
+ *between* consecutive blocks (never above the first or below the last), so both
+ sides always have content.
+2. **Split inheritance:** the new note inherits **source + folder + tags**. Its title is
+ the first heading found in the part below, falling back to `" (2)"`.
+ **No AI re-analysis.** After splitting, the user stays on the current (top) note; a
+ toast links to the new note.
+3. **Delete safety:** **both** a confirmation prompt **and** an undo. Delete asks to
+ confirm, performs the edit, then shows an "Undo" affordance for ~6 seconds that
+ restores the previous `content_md`.
+
+## Architecture
+
+### Tool mode (replaces the highlighter's `armed` boolean)
+
+`ChatDetail` currently tracks `armed: boolean` for the highlighter. Replace it with a
+single mutually-exclusive mode:
+
+```ts
+type ToolMode = "none" | "highlight" | "cut";
+```
+
+Only one tool is active at a time; activating the scissors turns the highlighter off and
+vice-versa. `Esc` returns to `"none"`. Switching chats resets to `"none"`. The content
+container gets `hl-armed` when `tool === "highlight"` and `cut-armed` (crosshair) when
+`tool === "cut"`.
+
+### Block source positions (extend `rehypeSourcePositions`)
+
+The plugin already wraps text nodes in ``. Extend
+it so that, in the same walk, every **block element** in this set —
+`p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, table, hr` — that has
+`position.start.offset` also gets `data-md-block-start=""`. (Text-node wrapping
+is unchanged; this only adds an attribute to block elements.) `li` block-start includes
+the list marker (`- `, `1. `), so cutting between list items splits the list cleanly.
+
+### Cut-point mapping (`src/lib/cut-dom.ts`, DOM)
+
+- `interface CutBoundary { y: number; offset: number }` — `y` is in the scroll-content
+ coordinate space of the container; `offset` is the `content_md` offset of the block
+ **below** the gap.
+- `getCutBoundaries(container): CutBoundary[]` — collect elements with
+ `data-md-block-start` in document order; for each consecutive pair `(a, b)`, emit
+ `{ y: midpoint of the gap between a.bottom and b.top, offset: b's block-start }`.
+ Coordinates are computed relative to the container's content
+ (`rect.top - container rect.top + container.scrollTop`) so they are stable across
+ scroll.
+- `nearestBoundary(boundaries, mouseYInContent): CutBoundary | null` — the boundary with
+ `y` closest to the mouse.
+
+### Pure markdown ops (`src/lib/cut.ts`, DOM-free, unit-tested)
+
+- `deleteAbove(md, offset): string` → `md.slice(offset)` with leading whitespace trimmed.
+- `deleteBelow(md, offset): string` → `md.slice(0, offset)` with trailing whitespace trimmed.
+- `splitMarkdown(md, offset): { above: string; below: string }` → trimmed halves.
+- `deriveSplitTitle(belowMd, originalTitle): string` → text of the first markdown heading
+ in `belowMd` (`/^#{1,6}\s+(.+?)\s*#*\s*$/m` on the first matching line), else
+ `" (2)"`.
+
+### Split persistence (`useDatabase.splitChat`)
+
+```ts
+splitChat(chatId: string, offset: number): Promise // returns the new (below) note
+```
+
+1. Load the chat; compute `{ above, below } = splitMarkdown(chat.content_md, offset)`.
+2. Insert a new chat via `db.insertChat`: `title = deriveSplitTitle(below, chat.title)`,
+ `summary = null`, `source = chat.source`, `content_md = below`, `content_html = null`,
+ `imported_at = now`, `chat_date = chat.chat_date`, `folder_id = chat.folder_id`.
+3. Copy tags: for each tag of the original (`db.getTagsForChat`), `db.addTagToChat(new.id, tag.id)`.
+4. Update the current chat: `db.updateChat(chatId, { content_md: above })`.
+5. Refresh chats/tags/folders; keep the current chat selected. Return the new chat.
+
+## UX & components
+
+- **`MarkdownToolbar`**: now renders two tool buttons — the existing highlighter and a
+ new **scissors** button. Each toggles its mode; the active one is visually pressed.
+- **Cut mode active**: crosshair cursor over the content; on `mousemove` the guide line
+ (a full-width absolutely-positioned element inside the content container) moves to the
+ nearest gap. The container needs `position: relative` for the overlay.
+- **Click** (left) on the content while in cut mode opens a small **menu** positioned at
+ the click, with *Delete above* / *Delete below* / *Split*, acting on the currently
+ shown boundary. The menu closes on outside click, `Esc`, or after an action.
+- **Delete above/below**: a confirmation (`Delete the part above/below this line?`) →
+ on confirm, `onUpdateChat(chat.id, { content_md })` and show an **Undo** toast for ~6s.
+ Undo restores the saved previous `content_md`. The saved snapshot is dropped when the
+ timer fires or the chat changes.
+- **Split**: `splitChat(chat.id, offset)`; stay on the current note; show a toast
+ `Note split → Open new note` linking to the created note. Non-destructive, no confirm.
+
+### Files
+
+- **Change** `src/lib/highlight.ts` — extend `rehypeSourcePositions` to annotate blocks.
+- **Create** `src/lib/cut.ts` — pure markdown ops + title derivation (unit-tested).
+- **Create** `src/lib/cut-dom.ts` — `getCutBoundaries`, `nearestBoundary`.
+- **Create** `src/components/ChatDetail/CutOverlay.tsx` — the guide line + action menu.
+- **Change** `src/components/ChatDetail/MarkdownToolbar.tsx` — two tools, `tool` prop.
+- **Change** `src/components/ChatDetail/ChatDetail.tsx` — `ToolMode` state, cut handlers,
+ confirm/undo/toast, render `CutOverlay` in both normal and focus views.
+- **Change** `src/hooks/useDatabase.ts` — `splitChat`; thread it to `ChatDetail`.
+- **Change** `src/index.css` — scissors button, crosshair, guide line, menu, toast.
+
+## Edge cases & caveats
+
+- **Code/mermaid blocks** are single cut units: you can cut around them, not inside.
+- **De-risk first:** confirm block elements carry `position.start.offset` after
+ `rehype-raw` (a `bun test` over the real pipeline, like the highlight de-risk).
+- **Undo window:** the pre-delete `content_md` is held only until the timer fires or the
+ chat changes; after that the only recovery is a snapshot.
+- **Boundary trimming:** slicing at a block-start and trimming whitespace keeps both
+ halves valid markdown; the inter-block blank line is dropped from whichever side loses it.
+- **Empty results are prevented** because the line only appears between two blocks.
+- **Search index:** `splitChat` and deletes flow through `db.insertChat`/`db.updateChat`,
+ which already re-index (with `stripHighlights`). No extra work.
+- **Highlights across the cut:** a `` fully on one side moves with that side; a
+ `` that the cut would bisect is rare (cuts are at block boundaries, marks are
+ within a block) — not specially handled in v1.
+
+## Non-goals (v1)
+
+- Cutting inside a paragraph/code block (sub-block precision).
+- Undo for Split (it is non-destructive; recover by merging manually).
+- AI re-analysis of the split-off note.
+- Merging two notes back together (the inverse of split).
+
+## Testing strategy
+
+- **Unit (`cut.ts`):** `deleteAbove`/`deleteBelow`/`splitMarkdown` trimming; `deriveSplitTitle`
+ with/without a leading heading.
+- **De-risk test:** block elements have source offsets after the real pipeline.
+- **Plugin test:** blocks (incl. `li`) get `data-md-block-start` matching the source.
+- **Manual:** snap line between paragraphs / list items / around a code block; delete
+ above/below with confirm + undo; split and verify the new note inherits source/folder/
+ tags and a sensible title; tool mutual-exclusivity and `Esc`.
diff --git a/src/App.tsx b/src/App.tsx
index c07802b..832e4e8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -56,7 +56,7 @@ export default function App() {
const {
chats, recentChats, tags, folders, unfiledCount, selectedChat, selectedChatTags, selectedChatAttachments,
searchQuery, selectedTagIds, selectedSource, selectedFolderId, loading, generatingMetadata,
- setSelectedChat, checkDuplicate, updateExistingChat, importFile, updateChat, toggleFavorite, deleteChat,
+ setSelectedChat, checkDuplicate, updateExistingChat, importFile, splitChat, updateChat, toggleFavorite, deleteChat,
createTag, updateTag, deleteTag,
addTagToChat, removeTagFromChat, addAttachment, removeAttachment,
toggleTag, selectTag, clearTags, selectSource, search,
@@ -496,6 +496,11 @@ export default function App() {
isResizing={isResizing}
focusMode={focusMode}
onToggleFocus={() => setFocusMode(f => !f)}
+ onSplitChat={splitChat}
+ onOpenChat={(id) => {
+ const c = chats.find((x) => x.id === id);
+ if (c) setSelectedChat(c);
+ }}
/>
>
) : !focusMode && filteredChats.length > 0 ? (
diff --git a/src/components/ChatDetail/ChatDetail.tsx b/src/components/ChatDetail/ChatDetail.tsx
index 72f0c56..0cca161 100644
--- a/src/components/ChatDetail/ChatDetail.tsx
+++ b/src/components/ChatDetail/ChatDetail.tsx
@@ -7,7 +7,9 @@ import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import { rehypeSourcePositions, applyHighlight, removeHighlight, newHighlightId, stripHighlights } from "../../lib/highlight";
-import MarkdownToolbar from "./MarkdownToolbar";
+import { deleteAbove, deleteBelow } from "../../lib/cut";
+import MarkdownToolbar, { type ToolMode } from "./MarkdownToolbar";
+import CutOverlay from "./CutOverlay";
import { computeSourceRanges } from "../../lib/highlight-dom";
import { extractHeadings } from "../../lib/parser";
import { jsxToHtml } from "../../lib/jsx-preview";
@@ -142,6 +144,8 @@ interface ChatDetailProps {
isResizing?: boolean;
focusMode?: boolean;
onToggleFocus?: () => void;
+ onSplitChat: (chatId: string, offset: number) => Promise;
+ onOpenChat: (chatId: string) => void;
}
function getTextContent(node: React.ReactNode): string {
@@ -173,7 +177,7 @@ const sourceLabels: Record = { claude: "Claude", perplexity: "Pe
export default function ChatDetail({
chat, tags, allTags, attachments, onUpdateChat, onClose,
- onAddTag, onRemoveTag, onCreateTag, onAddAttachment, onRemoveAttachment, onRegenerateField, onReparseHtml, isResizing, focusMode, onToggleFocus,
+ onAddTag, onRemoveTag, onCreateTag, onAddAttachment, onRemoveAttachment, onRegenerateField, onReparseHtml, isResizing, focusMode, onToggleFocus, onSplitChat, onOpenChat,
}: ChatDetailProps) {
const [editingTitle, setEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState(chat.title);
@@ -196,8 +200,13 @@ export default function ChatDetail({
const hlNoticeTimer = useRef | null>(null);
const tagDropdownRef = useRef(null);
const tocDragging = useRef(false);
- const [armed, setArmed] = useState(false);
+ const [tool, setTool] = useState("none");
const [hlNotice, setHlNotice] = useState(null);
+ const [cutConfirm, setCutConfirm] = useState<{ direction: "above" | "below"; offset: number } | null>(null);
+ const [cutUndo, setCutUndo] = useState(null); // previous content_md
+ const [splitToast, setSplitToast] = useState(null); // new chat id
+ const cutUndoTimer = useRef | null>(null);
+ const splitToastTimer = useRef | null>(null);
useEffect(() => {
setEditingTitle(false);
@@ -207,9 +216,14 @@ export default function ChatDetail({
setShowTagDropdown(false);
setShowChatSearch(false);
setChatSearchTerm("");
- setArmed(false);
+ setTool("none");
setHlNotice(null);
if (hlNoticeTimer.current) { clearTimeout(hlNoticeTimer.current); hlNoticeTimer.current = null; }
+ setCutConfirm(null);
+ setCutUndo(null);
+ setSplitToast(null);
+ if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; }
+ if (splitToastTimer.current) { clearTimeout(splitToastTimer.current); splitToastTimer.current = null; }
}, [chat.id]);
// Sync local state when chat data changes externally (e.g. after analysis)
@@ -319,10 +333,43 @@ export default function ChatDetail({
const handleTitleSave = useCallback(() => { onUpdateChat(chat.id, { title: titleValue }); setEditingTitle(false); }, [chat.id, titleValue, onUpdateChat]);
const handleSummarySave = useCallback(() => { onUpdateChat(chat.id, { summary: summaryValue }); }, [chat.id, summaryValue, onUpdateChat]);
- // Toggle the highlighter on/off (like picking up / putting down a marker).
- const handleToggleHighlighter = useCallback(() => {
- setArmed((prev) => !prev);
+ const handleToggleHighlight = useCallback(() => {
+ setTool((t) => (t === "highlight" ? "none" : "highlight"));
}, []);
+ const handleToggleCut = useCallback(() => {
+ setTool((t) => (t === "cut" ? "none" : "cut"));
+ }, []);
+
+ const performDelete = useCallback((direction: "above" | "below", offset: number) => {
+ const prev = chat.content_md;
+ const next = direction === "above" ? deleteAbove(prev, offset) : deleteBelow(prev, offset);
+ onUpdateChat(chat.id, { content_md: next });
+ setCutUndo(prev);
+ if (cutUndoTimer.current) clearTimeout(cutUndoTimer.current);
+ cutUndoTimer.current = setTimeout(() => setCutUndo(null), 6000);
+ // Clear any pending split toast so only one toast is live at a time.
+ setSplitToast(null);
+ if (splitToastTimer.current) { clearTimeout(splitToastTimer.current); splitToastTimer.current = null; }
+ }, [chat.id, chat.content_md, onUpdateChat]);
+
+ const handleCutUndo = useCallback(() => {
+ if (cutUndo == null) return;
+ onUpdateChat(chat.id, { content_md: cutUndo });
+ setCutUndo(null);
+ if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; }
+ }, [cutUndo, chat.id, onUpdateChat]);
+
+ const handleSplit = useCallback(async (offset: number) => {
+ const newChat = await onSplitChat(chat.id, offset);
+ if (newChat) {
+ setSplitToast(newChat.id);
+ if (splitToastTimer.current) clearTimeout(splitToastTimer.current);
+ splitToastTimer.current = setTimeout(() => setSplitToast(null), 8000);
+ // Clear any pending delete-undo toast so only one toast is live at a time.
+ setCutUndo(null);
+ if (cutUndoTimer.current) { clearTimeout(cutUndoTimer.current); cutUndoTimer.current = null; }
+ }
+ }, [chat.id, onSplitChat]);
// Clicking an existing highlight erases it.
const handleMarkRemove = useCallback((id: string) => {
@@ -332,7 +379,7 @@ export default function ChatDetail({
// Armed-highlighter: while the marker is on, finishing a drag-selection in the
// content applies a yellow highlight — no need to click a button afterwards.
useEffect(() => {
- if (!armed) return;
+ if (tool !== "highlight") return;
const container = contentRef.current;
if (!container) return;
const onMouseUp = () => {
@@ -350,15 +397,14 @@ export default function ChatDetail({
};
container.addEventListener("mouseup", onMouseUp);
return () => container.removeEventListener("mouseup", onMouseUp);
- }, [armed, chat.id, chat.content_md, onUpdateChat, focusMode]);
+ }, [tool, chat.id, chat.content_md, onUpdateChat, focusMode]);
- // Esc turns the highlighter off.
useEffect(() => {
- if (!armed) return;
- const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setArmed(false); };
+ if (tool === "none") return;
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setTool("none"); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [armed]);
+ }, [tool]);
const handleAttachFile = useCallback(async () => {
const selected = await open({ multiple: false, directory: false });
@@ -452,6 +498,36 @@ export default function ChatDetail({
const importDate = new Date(chat.imported_at).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
+ const cutModals = (
+ <>
+ {cutConfirm && (
+
setCutConfirm(null)}>
+
e.stopPropagation()}>
+
+ Delete the part {cutConfirm.direction} this line?
+
+
+
+
+
+
+
+ )}
+ {cutUndo != null && (
+
+ Content deleted
+
+
+ )}
+ {splitToast && (
+
+ Note split
+
+
+ )}
+ >
+ );
+
if (focusMode) {
return (
@@ -461,15 +537,26 @@ export default function ChatDetail({