diff --git a/CLAUDE.md b/CLAUDE.md index b4f40e9..bb5e0e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,18 @@ panels**, and a few responsibilities are deliberately split across the AppKit/Sw image window a double-click opens (AppKit `ZoomScrollView` zoom + Open in Preview), dressed like its note (paper/glass background, note-style header, hints) — one reusable `ImageWindow` owned by `NoteWindowManager`; far quicker than launching Preview. -- **App shell** — `TicApp` (`@main`) provides a `MenuBarExtra`; `AppDelegate` +- **MCP server (`MCP/`)** — lets AI agents drive Tic. `MCPService` runs an `NWListener` on a Unix + socket beside the DB (`~/Library/Application Support/Tic/mcp.sock`, 0600) — one official-SDK + `Server` session per connection (per agent). `MCPTools` is the tool surface (`create_note`, + `add_tasks`, `update_task`, `move_task`, …): pure `AppDatabase` writes plus three window pokes + (`WindowActions`), reusing `TaskOutline` for every structural rule so tools and UI can't diverge. + Clients speak **stdio** to `Tic --mcp` (`MCPProxy`), which pipes to the socket — one config works + for every client, and the server lives in the app next to the observers. Off by default + (`AppModel.mcpEnabled`). **Live updates are free:** tools write the DB, the existing + `ValueObservation` streams push into open panels; the one addition is `observeNote(id:)` so the + controller streams its own note row too (an agent's title/colour/flag write shows live). +- **App shell** — `TicApp` provides a `MenuBarExtra`; `main.swift` (not `@main`) routes `--mcp` to + the proxy, else `TicApp.main()`; `AppDelegate` (`NSApplicationDelegateAdaptor`) builds the shared `AppDatabase` + `NoteWindowManager` and calls `restoreAll()` on launch. The app is a **hybrid**: Dock icon **and** menu bar item. diff --git a/PLAN.md b/PLAN.md index 3a05f42..6755ace 100644 --- a/PLAN.md +++ b/PLAN.md @@ -159,6 +159,183 @@ deferred to the distribution step. 6. Menu bar shows correct count of today's open tasks. - Optionally drive the built app with the `run` / `verify` skills once it compiles. +## MCP: AI agents drive Tic (planned, next) + +Agents (Claude Desktop/Code, Cursor, VS Code, Codex, Gemini, Windsurf, Zed, …) get **full +parity with a human** over notes and tasks through an MCP server, and the UI reflects every +agent write instantly. Off by default; a menu-bar item **AI Agents (MCP)…** opens the setup +window that holds the toggle and per-client install instructions. + +### The one decision that keeps this small: the server runs *inside* the running Tic + +- **Live updates cost nothing.** Every tool writes through the existing `AppDatabase`, and the + existing `ValueObservation` streams (`observeTasks`, `observeTaskImageCrops`, `observeNotes`) + already push those writes into the views. A standalone `tic-mcp` binary writing the SQLite file + would need a hand-built cross-process notification channel: GRDB does not observe other + processes' writes. +- **One gap to close:** an open note's `NoteController` streams its tasks but holds its `note` + row as a one-time snapshot, and `NoteView` seeds the title field once. Add + `AppDatabase.observeNote(id:)` (per-row twin of `observeNotes()`), subscribe in + `NoteController.start()`, diff each emission against the current `note` and fire the existing + closures when their fields changed (`onApplyBehavior` for float/all-Spaces, `onSetCollapsed` + for roll-up). Diffing against the optimistic in-memory value makes the controller's own writes + echo back as no-ops. `NoteView` syncs `titleText` from `controller.note.title` while the field + isn't focused. Colour/material/list options already render from `controller.note`. +- **Window actions go direct, not via reconcile.** Open/close/bring-to-front/move hop to the + main actor and call `NoteWindowManager` (`openNote`, new `closeNote(id)`, `setFrame(id:)`). + Reconciling panels from the notes observer was considered and dropped: `openNote` writes + `isOpen` asynchronously, so an emission in that gap looks like a closed note with a panel. + A programmatic `setFrame` fires `windowDidMove`/`windowDidResize`, so the existing debounced + save persists an agent move with no new code. + +### Transport: stdio to the client, a Unix socket inside + +``` +Claude / Cursor / … ──stdio──▶ Tic --mcp (proxy) ──unix socket──▶ Tic.app: MCPServer ─▶ AppDatabase + one connection per client one serialised writer +``` + +- **Clients see stdio only**, the one transport every MCP client supports. Every client config + is identical: `command: /Contents/MacOS/Tic`, `args: ["--mcp"]`. +- **The proxy is the same binary.** `@main` on `TicApp` becomes a `main.swift`: `--mcp` runs + `MCPProxy` (pipe stdin → socket, socket → stdout, exit on EOF) instead of the GUI. No second + target, no packaging change. If the socket is missing, launch Tic via + `NSWorkspace.openApplication(at:)` without activating and retry for a few seconds; if it's + still missing (MCP off) print a one-line hint to stderr and exit 1, which clients surface in + their logs. Under `swift run` the path is `.build/debug/Tic`, so dev testing works the same. +- **The inner hop is a Unix domain socket** at `~/Library/Application Support/Tic/mcp.sock` + (Network.framework: `NWListener` with `requiredLocalEndpoint = .unix(path:)`, available since + macOS 10.15). Chosen over localhost TCP because it needs no port, triggers no macOS firewall + prompt, and is a 0600 file in the user's Library, the same exposure as the SQLite file, not a + port open to every local process and browser page. Unlink a stale socket before binding. + (`sun_path` max is 104 bytes; the App Support path is ~50.) +- **Many agents at once** just work: the listener accepts one connection per client, each is its + own MCP session (own `initialize`, own request ids, handled in order per connection), and all + writes serialise through the `DatabaseQueue` (`MAX(sortIndex)+1` is computed inside the write). + Two agents editing one task is last-write-wins, the same rule as user-vs-agent today. +- Skipped: Streamable HTTP (needs an HTTP+SSE server or a dependency, Origin/auth per spec, the + firewall prompt, and Claude Desktop's config file still only spawns stdio). Add if a client + that can't spawn a process shows up. + +### Protocol: hand-rolled, five methods + +Newline-delimited JSON-RPC 2.0, exactly MCP's stdio framing, so the proxy pipes bytes +untouched. `MCPServer` handles `initialize` (echo the client's `protocolVersion` when known, +else ours; `capabilities.tools`; a short `instructions` string describing notes, the +three-level outline and the completion cascade), `notifications/initialized` (ignored), +`ping`, `tools/list`, `tools/call`; anything else → `-32601`. The dispatcher is a pure +`handle(line) async -> String?` over an `AppDatabase`, so tests drive it with strings on an +in-memory DB. The official swift-sdk is skipped (three transitive dependencies for five +methods); switch if resources/prompts/sampling are ever wanted. + +### Tools: everything a human can do to notes and tasks + +| Tool | Covers | Path | +|---|---|---| +| `list_notes`, `get_note` | Lists palette; reading a note (tasks with id/text/level/done/has_image) | reads | +| `create_note` | ⌘N, with title/color/material/tasks/frame; cascaded placement like `newNote` | insert + `openNote` | +| `update_note` | title, color, material, float, all-Spaces, collapsed, hide-completed, sort-to-bottom, `open` (X / bring to front) | targeted column writes → note observer; `open` → manager | +| `move_note` | drag/resize; global coordinates spanning displays, as stored | manager `setFrame` | +| `delete_note` | palette delete | `destructiveHint` | +| `add_tasks` | quick-add, add subtask, add sibling (`after_task_id` + `level`) | `insertTask` / `insertTask(reordering:)` | +| `update_task` | edit text; tick/untick with the bidirectional cascade | `update` / `applyingToggle` + `updateTaskCompletion` | +| `move_task` | drag reorder, indent, outdent | `movingSubtree` + `normalizedLevels` + `applyStructuralUpdate` | +| `delete_tasks`, `clear_completed` | delete, clear completed | `destructiveHint` | +| `set_task_image`, `crop_task_image`, `remove_task_image` | paste, crop (0…1 top-left rect), remove; base64 PNG/JPEG | `TaskImage` helpers + existing writes | + +- **Trust boundary validation:** unknown ids → tool error (`isError: true`), text trimmed and + capped, blank text rejected (deletion is explicit), level 0…`TaskItem.maxIndentLevel`, batch + and image size capped, colors/materials by raw value. +- **Conventions carry over:** completion changes only via the toggle cascade; structural edits + never touch `isDone`; level diffs by id (`indentLevelChanges`); targeted column writes only. + `NoteController.completionChanges` moves to `TaskOutline` so both callers share it. +- **Annotations** (`readOnlyHint`, `destructiveHint`, `idempotentHint`) ride along so clients ask + the user before an agent deletes anything. +- **Defaults:** a write to a closed note reopens it so the user sees the work. +- **Excluded on purpose:** launch at login, the MCP toggle itself, quit, update check, the + transient image viewer window. App controls, not note content. + +### Setup window (signed-off mockup) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ AI Agents (MCP) ✕ │ +│ Let Claude, Cursor and friends draft and tick your lists. │ +│ │ +│ (● ) Enable MCP server ● Running · 1 connected │ +│ │ +│ Claude Desktop ▸ │ Add this to claude_desktop_config.json │ +│ Claude Code │ ┌────────────────────────────────────┐ │ +│ Cursor │ │ { "mcpServers": { "tic": { │ │ +│ VS Code │ │ "command": "/Applications/…/Tic",│ │ +│ Codex CLI │ │ "args": ["--mcp"] } } } │ │ +│ Gemini CLI │ └────────────────────────────────────┘ │ +│ Windsurf │ [ Copy ] [ Open config file ] │ +│ Zed │ │ +│ Other (JSON) │ CLIs show a one-line `… mcp add` command │ +│ │ instead; Cursor gets an "Add to Cursor" │ +│ │ deeplink button. │ +│ │ +│ ⚠ Tic isn't in /Applications, the path changes if you move it│ +└──────────────────────────────────────────────────────────────┘ +``` + +- Built like the Lists palette: an `NSPanel` hosting SwiftUI, owned by `AppModel` + (`openMCPSetup()`), `.regularMaterial`, ~640×420. +- `MCPClients` is one static table: name, config path, snippet shape (`mcpServers` JSON / + `servers` JSON / TOML / CLI command), optional deeplink. Adding a client is one array entry. + The executable path is read live from `Bundle.main.executableURL`, so it's right for dev too. +- Preference `mcpEnabled` (UserDefaults, default off); `AppModel.setMCPEnabled` starts/stops the + listener; `bootstrap()` starts it when on. Status text is the listener's live connection count. +- No activity feed and no highlight animation on agent edits in v1 (the live update already + shows them). Add the feed when debugging agents gets annoying. + +### Files (~1,000 lines incl. tests) + +``` +Sources/Tic/ + main.swift --mcp → MCPProxy, else TicApp.main() (@main removed) + MCP/MCPServer.swift NWListener, line framing, JSON-RPC dispatch, sessions + MCP/MCPTools.swift the tool table + handlers over AppDatabase (+ main-actor window calls) + MCP/MCPProxy.swift stdio ↔ socket bridge, auto-launch, off-hint + MCP/MCPClients.swift client instruction table + Views/MCPSetupView.swift the window + AppModel.swift mcpEnabled, start/stop, openMCPSetup() + TicApp.swift menu item + Database/AppDatabase.swift observeNote(id:) + Controllers/NoteController.swift note-row observation + diff → closures + Windows/NoteWindowManager.swift closeNote(id:), setFrame(id:_:) + Views/NoteView.swift titleText sync + Models/TaskOutline.swift completionChanges (moved from the controller) +Tests/TicTests/ + MCPServerTests.swift handshake, tools/list, unknown method, framing split/joined lines + MCPToolsTests.swift create→get round trip, level clamping, done cascade, move re-nest, + update_note/move_note round trip, bad ids, image set/crop/remove + NoteControllerTests.swift note observer fires closures on change, quiet on echo; rename lands +``` + +### Build order + +1. **Spike** (`main.swift`, `MCPProxy`, an echo listener). Verify: the Unix-socket listener + accepts connections; `swift test` still works with `main.swift` instead of `@main`; a client + spawning the GUI binary with `--mcp` shows no Dock icon; `claude mcp add tic -- + $PWD/.build/debug/Tic --mcp` then `claude mcp list` reports it connected. +2. **Protocol + tools + tests**: `MCPServer`, `MCPTools`, `observeNote`, controller observer, + `closeNote`/`setFrame`, `titleText` sync. +3. **Toggle + menu item**, then the **setup window** per the mockup. +4. **Docs**: CLAUDE.md conventions (in-process server, stdio-via-`--mcp`, note observer, direct + window calls), README and site "Works with AI agents" mention. + +### Verification + +- `swift build`, `swift test` (the MCP suites drive the dispatcher with JSON-RPC lines over an + in-memory database). +- Manual, user-driven: enable MCP, add Tic to Claude Code with the command above, then ask it to + draft a list. Watch: the note appears cascaded; asking it to rename, recolour, roll up, float, + move, tick a parent (subtree ticks), add subtasks, and delete a task each show instantly in the + open panel. Toggle MCP off → the agent's next call fails with the stderr hint. Connect a second + client and confirm "2 connected" and that both see each other's writes via `get_note`. + ## Open follow-ups (post-MVP, not built now) Daily-vs-planned rollover (the signature feature — a Today note that carries unfinished items to diff --git a/Package.resolved b/Package.resolved index 0cb602e..a8008b6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "025b79e0ac833ab358ebfafff63ef29f322fcd8b51340d7f033ce227b99d5c28", + "originHash" : "fca8f533f2fd636e29c6707de865c81ac52fded53435f336d8c74f1ed9b228fa", "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/eventsource.git", + "state" : { + "revision" : "86b5096ac59ab46e66bd1f6377c604bc1dab0bc2", + "version" : "1.5.1" + } + }, { "identity" : "grdb.swift", "kind" : "remoteSourceControl", @@ -9,6 +18,60 @@ "revision" : "b83108d10f42680d78f23fe4d4d80fc88dab3212", "version" : "7.11.1" } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", + "state" : { + "revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605", + "version" : "0.12.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 3797f21..96fe609 100644 --- a/Package.swift +++ b/Package.swift @@ -7,13 +7,15 @@ let package = Package( .macOS(.v14) ], dependencies: [ - .package(url: "https://github.com/groue/GRDB.swift", from: "7.0.0") + .package(url: "https://github.com/groue/GRDB.swift", from: "7.0.0"), + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", .upToNextMinor(from: "0.12.1")) ], targets: [ .executableTarget( name: "Tic", dependencies: [ - .product(name: "GRDB", package: "GRDB.swift") + .product(name: "GRDB", package: "GRDB.swift"), + .product(name: "MCP", package: "swift-sdk") ], resources: [ .process("Resources") @@ -21,7 +23,10 @@ let package = Package( ), .testTarget( name: "TicTests", - dependencies: ["Tic"] + dependencies: [ + "Tic", + .product(name: "MCP", package: "swift-sdk") + ] ) ] ) diff --git a/Sources/Tic/AppModel.swift b/Sources/Tic/AppModel.swift index e224ba6..cf4bd3d 100644 --- a/Sources/Tic/AppModel.swift +++ b/Sources/Tic/AppModel.swift @@ -15,6 +15,7 @@ final class AppModel { let database: AppDatabase let windows: NoteWindowManager let updates = UpdateChecker() + @ObservationIgnored private let mcp: MCPService /// All saved notes (ascending `sortIndex`, i.e. creation order) — drives the menu bar list. /// Stays in sync as notes are created, renamed, or deleted. @@ -22,6 +23,7 @@ final class AppModel { @ObservationIgnored private var notesObservation: Task? @ObservationIgnored private var searchWindow: NSWindow? + @ObservationIgnored private var mcpWindow: NSWindow? private init() { // The DB lives in Application Support; fall back to in-memory so the app still runs if @@ -31,7 +33,18 @@ final class AppModel { } self.database = db self.windows = NoteWindowManager(appDatabase: db) + self.mcp = MCPService(database: db) NSLog("[Tic] Database ready at \(db.path)") + mcp.onConnectionCountChange = { [weak self] count in + Task { @MainActor in self?.mcpConnections = count } + } + // Window pokes the MCP tools need, hopped to the main actor (the service runs off it). + mcp.windowActions = MCPTools.WindowActions( + open: { [weak self] id in await self?.windows.openNoteByID(id) }, + close: { [weak self] id in await self?.windows.closeNoteByID(id) }, + setFrame: { [weak self] id, rect in await self?.windows.setFrame(id, to: rect) }, + focus: { [weak self] id in await self?.windows.focusNoteByID(id) } + ) } /// Run once after launch: open saved note panels, then start streaming the notes list. @@ -39,6 +52,7 @@ final class AppModel { await windows.restoreAll() startObservingNotes() applyLaunchAtLogin(launchAtLogin) // honor the saved preference (effective when packaged) + if mcpEnabled { applyMCPEnabled(true) } updates.start() } @@ -119,6 +133,71 @@ final class AppModel { searchWindow?.orderOut(nil) } + /// Opens the "AI Agents (MCP)" setup window (the toggle + per-client install snippets). A single + /// reused, centered, floating panel — same treatment as the Lists palette. + func openMCPSetup() { + if mcpWindow == nil { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 680, height: 460), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, defer: false + ) + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isFloatingPanel = true + panel.level = .floating + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.isMovableByWindowBackground = true + panel.isOpaque = false + panel.backgroundColor = .clear + panel.standardWindowButton(.closeButton)?.isHidden = true + panel.standardWindowButton(.miniaturizeButton)?.isHidden = true + panel.standardWindowButton(.zoomButton)?.isHidden = true + panel.contentView = NSHostingView(rootView: MCPSetupView()) + mcpWindow = panel + } + guard let window = mcpWindow else { return } + window.center() + window.level = .floating + window.makeKeyAndOrderFront(nil) + NSApp.activate() + } + + func dismissMCPSetup() { + mcpWindow?.orderOut(nil) + } + + // MARK: - MCP (AI agents) + + private static let mcpEnabledKey = "mcpEnabled" + + /// Whether AI agents may drive Tic over MCP. Off by default and remembered; the socket exists + /// only while this is on, so a client spawning `Tic --mcp` meanwhile fails fast with a hint. + private(set) var mcpEnabled: Bool = UserDefaults.standard.bool(forKey: AppModel.mcpEnabledKey) + + /// Agents connected right now (the MCP window's status line). + private(set) var mcpConnections = 0 + + var mcpSocketPath: String { mcp.socketPath } + + func setMCPEnabled(_ enabled: Bool) { + mcpEnabled = enabled + UserDefaults.standard.set(enabled, forKey: Self.mcpEnabledKey) + applyMCPEnabled(enabled) + } + + private func applyMCPEnabled(_ enabled: Bool) { + guard enabled else { return mcp.stop() } + do { + try mcp.start() + NSLog("[Tic] MCP listening at \(mcp.socketPath)") + } catch { + NSLog("[Tic] MCP failed to start: \(error)") + mcpEnabled = false + } + } + // MARK: - Launch at login private static let launchAtLoginKey = "launchAtLogin" diff --git a/Sources/Tic/Controllers/NoteController.swift b/Sources/Tic/Controllers/NoteController.swift index 3dcc4fd..7f0df1a 100644 --- a/Sources/Tic/Controllers/NoteController.swift +++ b/Sources/Tic/Controllers/NoteController.swift @@ -18,6 +18,7 @@ final class NoteController { @ObservationIgnored private let db: AppDatabase @ObservationIgnored private var observation: Task? + @ObservationIgnored private var noteObservation: Task? @ObservationIgnored private var imageObservation: Task? @ObservationIgnored private var loadingThumbnails: Set = [] @@ -55,6 +56,18 @@ final class NoteController { NSLog("[Tic] task observation ended for \(noteID): \(error)") } } + // The note's own row: an agent (or any other writer) changing the title, colour, flags, or + // list options shows up here live. Own writes echo back as no-ops (the diff sees no change). + noteObservation?.cancel() + noteObservation = Task { [weak self, db, noteID] in + do { + for try await updated in db.observeNote(id: noteID) { + if let updated { self?.applyObservedNote(updated) } + } + } catch { + NSLog("[Tic] note observation ended for \(noteID): \(error)") + } + } imageObservation?.cancel() imageObservation = Task { [weak self, db, noteID] in do { @@ -71,10 +84,27 @@ final class NoteController { func stop() { observation?.cancel() observation = nil + noteObservation?.cancel() + noteObservation = nil imageObservation?.cancel() imageObservation = nil } + /// Applies an observed note-row change: replaces `note`, and drives the live window only for the + /// fields that actually changed. The controller's own actions mutate `note` first, so their echo + /// is a no-op here (nothing double-applies). + private func applyObservedNote(_ updated: Note) { + let old = note + guard old != updated else { return } + note = updated + if old.floatOnTop != updated.floatOnTop || old.showOnAllSpaces != updated.showOnAllSpaces { + onApplyBehavior?(updated.floatOnTop, updated.showOnAllSpaces) + } + if old.isCollapsed != updated.isCollapsed { + onSetCollapsed?(updated.isCollapsed) + } + } + // MARK: - Task actions /// Adds a task at the end. `level` is the requested nesting depth (from the quick-add field's @@ -339,10 +369,7 @@ final class NoteController { /// The `isDone`/`completedAt` deltas between two same-ordered task lists. private func completionChanges(from before: [TaskItem], to after: [TaskItem]) -> [TaskCompletionUpdate] { - zip(before, after).compactMap { b, a in - guard b.isDone != a.isDone || b.completedAt != a.completedAt else { return nil } - return TaskCompletionUpdate(id: a.id, isDone: a.isDone, completedAt: a.completedAt) - } + TaskOutline.completionChanges(from: before, to: after) } } diff --git a/Sources/Tic/Database/AppDatabase+Tasks.swift b/Sources/Tic/Database/AppDatabase+Tasks.swift index aaa699a..5d05277 100644 --- a/Sources/Tic/Database/AppDatabase+Tasks.swift +++ b/Sources/Tic/Database/AppDatabase+Tasks.swift @@ -6,6 +6,10 @@ import GRDB extension AppDatabase { // MARK: - Tasks + func task(id: UUID) async throws -> TaskItem? { + try await dbQueue.read { db in try TaskItem.filter(TaskItem.Columns.id == id).fetchOne(db) } + } + func tasks(noteId: UUID) async throws -> [TaskItem] { try await dbQueue.read { db in try TaskItem @@ -146,6 +150,23 @@ extension AppDatabase { } } + /// Whether a task has an image (a cheap existence check, never loads the blob). + func hasImage(taskId: UUID) async throws -> Bool { + try await dbQueue.read { db in + try Bool.fetchOne(db, sql: "SELECT 1 FROM taskImage WHERE taskId = ?", arguments: [taskId]) ?? false + } + } + + /// The ids of a note's tasks that have an image — a one-shot for `get_note` (the observation twin + /// is `observeTaskImageCrops`). + func taskImageIds(noteId: UUID) async throws -> Set { + try await dbQueue.read { db in + Set(try UUID.fetchAll(db, sql: """ + SELECT i.taskId FROM taskImage i JOIN task t ON t.id = i.taskId WHERE t.noteId = ? + """, arguments: [noteId])) + } + } + /// The stored (original, uncropped) image bytes — read on demand, never via an observation. func taskImageData(taskId: UUID) async throws -> Data? { try await dbQueue.read { db in diff --git a/Sources/Tic/Database/AppDatabase.swift b/Sources/Tic/Database/AppDatabase.swift index a1c00e8..8357fab 100644 --- a/Sources/Tic/Database/AppDatabase.swift +++ b/Sources/Tic/Database/AppDatabase.swift @@ -14,8 +14,8 @@ final class AppDatabase: Sendable { // MARK: - Setup - /// The shared on-disk database at `~/Library/Application Support/Tic/tic.sqlite`. - static func makeShared() throws -> AppDatabase { + /// `~/Library/Application Support/Tic/` — home of the database and the MCP socket. + static func supportDirectory() throws -> URL { let fm = FileManager.default let appSupport = try fm.url( for: .applicationSupportDirectory, in: .userDomainMask, @@ -23,7 +23,12 @@ final class AppDatabase: Sendable { ) let dir = appSupport.appendingPathComponent("Tic", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) - let dbURL = dir.appendingPathComponent("tic.sqlite") + return dir + } + + /// The shared on-disk database at `~/Library/Application Support/Tic/tic.sqlite`. + static func makeShared() throws -> AppDatabase { + let dbURL = try supportDirectory().appendingPathComponent("tic.sqlite") let db = try AppDatabase(try DatabaseQueue(path: dbURL.path)) try db.seedSampleDataIfEmpty() @@ -236,6 +241,22 @@ final class AppDatabase: Sendable { } } + /// A single note fetched by id (nil if it was deleted). Used to open a note an agent created + /// or wrote to while its panel was closed. + func note(id: UUID) async throws -> Note? { + try await dbQueue.read { db in + try Note.filter(Note.Columns.id == id).fetchOne(db) + } + } + + /// Emits one note whenever it changes (nil once deleted) — the per-row twin of `observeNotes`, + /// so an open panel reflects an agent's title/colour/flag writes live. + func observeNote(id: UUID) -> AsyncValueObservation { + ValueObservation + .tracking { db in try Note.filter(Note.Columns.id == id).fetchOne(db) } + .values(in: dbQueue) + } + /// Emits the full ordered list of notes whenever any note changes. func observeNotes() -> AsyncValueObservation<[Note]> { ValueObservation diff --git a/Sources/Tic/MCP/MCPClients.swift b/Sources/Tic/MCP/MCPClients.swift new file mode 100644 index 0000000..d568460 --- /dev/null +++ b/Sources/Tic/MCP/MCPClients.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Per-client install instructions for the setup window. One entry per popular MCP client; adding a +/// client is one array element. Every client spawns the same `Tic --mcp`, so the only thing that +/// varies is where the snippet goes (a config file to paste into, or a CLI command to run) and its +/// format (JSON `mcpServers`, VS Code / Zed shapes, or Codex TOML). +struct MCPClient: Identifiable, Sendable { + enum Install: Sendable { + case configFile(path: String) // snippet is pasted into this file (path may start with ~) + case command // snippet is a shell command to run + } + + let id: String // also the display name + let detail: String // the one-line "how" + let install: Install + let snippet: String + /// A one-click "Add to " URL, for clients that support an install deeplink. + var deeplink: String? = nil + + var name: String { id } + var configPath: String? { if case let .configFile(path) = install { return path } else { return nil } } +} + +enum MCPClients { + /// Percent-encodes a query value, escaping everything but the URL-unreserved set (so base64 + /// `+`/`/`/`=` and JSON punctuation survive the round trip). + private static func urlEncode(_ s: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s + } + + /// All clients, with the live executable path baked into each snippet. + static func all(executablePath exe: String) -> [MCPClient] { + let mcpServers = """ + { + "mcpServers": { + "tic": { + "command": "\(exe)", + "args": ["--mcp"] + } + } + } + """ + + // One-click install URLs. Cursor takes base64 of the inner server config; VS Code takes the + // URL-encoded server object. Both open the app and pre-fill the config. + let cursorConfig = "{\"command\":\"\(exe)\",\"args\":[\"--mcp\"]}" + let cursorLink = "cursor://anysphere.cursor-deeplink/mcp/install?name=tic&config=" + + urlEncode(Data(cursorConfig.utf8).base64EncodedString()) + let vscodeObject = "{\"name\":\"tic\",\"command\":\"\(exe)\",\"args\":[\"--mcp\"]}" + let vscodeLink = "vscode:mcp/install?" + urlEncode(vscodeObject) + + return [ + MCPClient(id: "Claude Desktop", + detail: "Add this to your Claude Desktop config, then restart Claude.", + install: .configFile(path: "~/Library/Application Support/Claude/claude_desktop_config.json"), + snippet: mcpServers), + + MCPClient(id: "Claude Code", + detail: "Run this once in your terminal.", + install: .command, + snippet: "claude mcp add tic -- \"\(exe)\" --mcp"), + + MCPClient(id: "Cursor", + detail: "Click Add to Cursor, or paste this into Cursor's MCP config.", + install: .configFile(path: "~/.cursor/mcp.json"), + snippet: mcpServers, deeplink: cursorLink), + + MCPClient(id: "VS Code", + detail: "Click Add to VS Code, or run this once in a terminal.", + install: .command, + snippet: "code --add-mcp '{\"name\":\"tic\",\"command\":\"\(exe)\",\"args\":[\"--mcp\"]}'", + deeplink: vscodeLink), + + MCPClient(id: "Codex CLI", + detail: "Add this block to your Codex config.", + install: .configFile(path: "~/.codex/config.toml"), + snippet: """ + [mcp_servers.tic] + command = "\(exe)" + args = ["--mcp"] + """), + + MCPClient(id: "Gemini CLI", + detail: "Add this to your Gemini CLI settings.", + install: .configFile(path: "~/.gemini/settings.json"), + snippet: mcpServers), + + MCPClient(id: "Windsurf", + detail: "Add this to Windsurf's MCP config, then refresh.", + install: .configFile(path: "~/.codeium/windsurf/mcp_config.json"), + snippet: mcpServers), + + MCPClient(id: "Zed", + detail: "Add this to your Zed settings.", + install: .configFile(path: "~/.config/zed/settings.json"), + snippet: """ + { + "context_servers": { + "tic": { + "command": { "path": "\(exe)", "args": ["--mcp"] } + } + } + } + """), + + MCPClient(id: "Other (JSON)", + detail: "Most clients accept this standard MCP server block.", + install: .command, + snippet: mcpServers), + ] + } +} diff --git a/Sources/Tic/MCP/MCPProxy.swift b/Sources/Tic/MCP/MCPProxy.swift new file mode 100644 index 0000000..2690fbf --- /dev/null +++ b/Sources/Tic/MCP/MCPProxy.swift @@ -0,0 +1,75 @@ +import AppKit +import Network + +/// `Tic --mcp`: the process every MCP client spawns. A dumb pipe between the client's stdio and the +/// running app's Unix socket — both sides speak newline-delimited JSON-RPC, so bytes pass untouched +/// and the real server (`MCPService`) lives in the app, next to the database observers. Launches Tic +/// when it isn't running; exits with a hint when the socket never appears (MCP is switched off). +enum MCPProxy { + @MainActor + static func run(socketPath: String = MCPService.socketPath) -> Never { + if !FileManager.default.fileExists(atPath: socketPath) { launchTic() } + connect(socketPath, attemptsLeft: 40) // 40 × 250ms: a cold launch plus the DB open + dispatchMain() + } + + /// Connects, retrying while the socket is missing (Tic still starting) or refusing (a stale file). + private static func connect(_ path: String, attemptsLeft: Int) { + let connection = NWConnection(to: .unix(path: path), using: .tcp) + connection.stateUpdateHandler = { state in + switch state { + case .ready: + pipe(connection) + case .failed, .waiting: + connection.cancel() + guard attemptsLeft > 0 else { + die("Tic: MCP is off. Enable it from the Tic menu bar icon → AI Agents (MCP)…") + } + DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(250)) { + connect(path, attemptsLeft: attemptsLeft - 1) + } + default: + break + } + } + connection.start(queue: .global()) + } + + private static func pipe(_ connection: NWConnection) { + FileHandle.standardInput.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { // the client closed our stdin: we're done + handle.readabilityHandler = nil + connection.cancel() + exit(0) + } + connection.send(content: data, completion: .contentProcessed { _ in }) + } + receive(connection) + } + + private static func receive(_ connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 1 << 16) { data, _, isComplete, error in + if let data, !data.isEmpty { try? FileHandle.standardOutput.write(contentsOf: data) } + if isComplete || error != nil { exit(0) } // Tic quit or MCP was switched off + receive(connection) + } + } + + /// The app bundle sits three levels above the executable (Tic.app/Contents/MacOS/Tic). A bare + /// `swift run` binary has no bundle, so in dev Tic simply has to be running already. + @MainActor + private static func launchTic() { + guard let exe = Bundle.main.executableURL else { return } + let bundle = exe.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + guard bundle.pathExtension == "app" else { return } + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + NSWorkspace.shared.openApplication(at: bundle, configuration: configuration) { _, _ in } + } + + private static func die(_ message: String) -> Never { + try? FileHandle.standardError.write(contentsOf: Data((message + "\n").utf8)) + exit(1) + } +} diff --git a/Sources/Tic/MCP/MCPService.swift b/Sources/Tic/MCP/MCPService.swift new file mode 100644 index 0000000..f1aaa89 --- /dev/null +++ b/Sources/Tic/MCP/MCPService.swift @@ -0,0 +1,141 @@ +import Foundation +import MCP +import Network + +/// The in-process MCP server. Listens on a Unix socket beside the database; every accepted +/// connection (one per `Tic --mcp` proxy, i.e. per agent) gets its own SDK `Server` session, so +/// agents never share a handshake or request ids. Tool handlers write through `AppDatabase`, and +/// the existing observers carry every change into the open notes — that is the whole "live update". +final class MCPService: @unchecked Sendable { + /// `~/Library/Application Support/Tic/mcp.sock`. A 0600 file next to the DB: the same exposure as + /// the DB itself, no port, no firewall prompt. (`sun_path` caps at 104 bytes; this is ~50.) + static var socketPath: String { + let dir = (try? AppDatabase.supportDirectory()) ?? URL(fileURLWithPath: NSTemporaryDirectory()) + return dir.appendingPathComponent("mcp.sock").path + } + + let socketPath: String + private let database: AppDatabase + /// Window pokes (open/close/move a note's panel), supplied by `AppModel` so this stays AppKit-free. + var windowActions = MCPTools.WindowActions() + /// Listener callbacks land here; `listener` and `sessions` are touched only on it. + private let queue = DispatchQueue(label: "tic.mcp") + private var listener: NWListener? + private var sessions: [ObjectIdentifier: Server] = [:] + /// Agents connected right now (the setup window's status line). Called off the main actor. + var onConnectionCountChange: (@Sendable (Int) -> Void)? + + /// Sent to the client on `initialize` so an agent uses the tools well. + static let instructions = """ + Tic is a desktop of floating sticky notes; each note is a checklist. Use these tools to \ + draft, tick, and organise the user's task lists — the app updates live as you work. + + Tasks are a flat, ordered list with an indent level 0-2 (max 3 levels deep). A task's \ + parent is implicit: the nearest earlier task at a shallower level. Build sub-items by \ + giving deeper levels; levels are auto-corrected to a valid outline. + + Completion cascades: ticking a parent ticks its whole subtree, and finishing the last \ + child auto-ticks the parent. Change done state only via update_task; moving or deleting \ + never changes ticks. Ids are UUID strings from list_notes / get_note. + + Writing to a note surfaces it above other apps without stealing the user's keyboard focus. + Use focus_note (or focus:true) only when you want to pull the user's attention to a note. + """ + + init(database: AppDatabase, socketPath: String = MCPService.socketPath) { + self.database = database + self.socketPath = socketPath + } + + var connectionCount: Int { queue.sync { sessions.count } } + + /// Binds the socket (replacing a stale file a crash left behind) and returns once it accepts, + /// so a failure (bad path, permissions) surfaces to the caller instead of a silent dead toggle. + func start() throws { + try? FileManager.default.removeItem(atPath: socketPath) + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .unix(path: socketPath) + let listener = try NWListener(using: parameters) + + let settled = DispatchSemaphore(value: 0) + nonisolated(unsafe) var failure: Error? + listener.stateUpdateHandler = { [socketPath] state in + switch state { + case .ready: + chmod(socketPath, 0o600) + settled.signal() + case .failed(let error): + failure = error + settled.signal() + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) } + listener.start(queue: queue) + _ = settled.wait(timeout: .now() + .seconds(2)) + if let failure { + listener.cancel() + throw failure + } + self.listener = listener + } + + /// Closes every session (each proxy sees EOF and exits) and removes the socket file, so a + /// client's next spawn fails fast with the "MCP is off" hint. + func stop() { + queue.sync { + listener?.cancel() + listener = nil + let open = sessions.values + sessions = [:] + for server in open { Task { await server.stop() } } + } + try? FileManager.default.removeItem(atPath: socketPath) + onConnectionCountChange?(0) + } + + // MARK: - Sessions + + private func accept(_ connection: NWConnection) { + // The SDK transport's defaults suit TCP clients, not an accepted server-side connection: the + // heartbeat sends raw bytes that would corrupt the client's stdio through the proxy, and a + // dropped accepted connection has nothing to reconnect to. + let transport = NetworkTransport( + connection: connection, + heartbeatConfig: .init(enabled: false), + reconnectionConfig: .init(enabled: false) + ) + let server = Server( + name: "Tic", + version: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev", + instructions: Self.instructions, + capabilities: .init(tools: .init()) + ) + let key = ObjectIdentifier(server) + sessions[key] = server + onConnectionCountChange?(sessions.count) + + let tools = MCPTools(database: database, window: windowActions) + Task { [weak self] in + await server.withMethodHandler(ListTools.self) { _ in ListTools.Result(tools: tools.definitions()) } + await server.withMethodHandler(CallTool.self) { params in + await tools.call(params.name, params.arguments) + } + do { + try await server.start(transport: transport) + } catch { + NSLog("[Tic] MCP session failed to start: \(error)") + } + await server.waitUntilCompleted() + self?.drop(key) + } + } + + private func drop(_ key: ObjectIdentifier) { + queue.async { [weak self] in + guard let self, self.sessions.removeValue(forKey: key) != nil else { return } + self.onConnectionCountChange?(self.sessions.count) + } + } +} diff --git a/Sources/Tic/MCP/MCPTools+Definitions.swift b/Sources/Tic/MCP/MCPTools+Definitions.swift new file mode 100644 index 0000000..03f5d7c --- /dev/null +++ b/Sources/Tic/MCP/MCPTools+Definitions.swift @@ -0,0 +1,148 @@ +import CoreGraphics +import Foundation +import MCP + +// The MCP tool schema (name, description, JSON-Schema input, annotations). Kept in its own +// extension so the handler struct stays focused on behaviour. +extension MCPTools { + // MARK: - Tool definitions + + + func definitions() -> [Tool] { + let readOnly = Tool.Annotations(readOnlyHint: true) + let destructive = Tool.Annotations(destructiveHint: true) + + return [ + Tool(name: "list_notes", + description: "List every Tic note (sticky) with its id, title, colour, and whether it's on screen. Call get_note for a note's tasks.", + inputSchema: object([:]), annotations: readOnly), + + Tool(name: "get_note", + description: "Get one note in full: its properties and its tasks (each with id, text, indent level 0-2, and done state).", + inputSchema: object(["note_id": prop("string", "The note's id")], required: ["note_id"]), + annotations: readOnly), + + Tool(name: "create_note", + description: "Create a new sticky note and open it on the desktop. Optionally seed it with tasks. Tasks build an outline: give each a level (0 top, 1-2 nested) to make sub-items.", + inputSchema: object([ + "title": prop("string", "Note title (optional; auto-named if omitted)"), + "color": enumProp(NoteColor.allCases.map(\.rawValue), "Note colour"), + "material": enumProp(["solid", "glass"], "Background style"), + "tasks": taskArraySchema, + "float_on_top": prop("boolean", "Keep the note above other windows"), + "show_on_all_spaces": prop("boolean", "Show the note on every desktop/Space"), + "collapsed": prop("boolean", "Start rolled up to just the title bar"), + "focus": prop("boolean", "Bring Tic to the front and focus the new note (default just surfaces it without stealing focus)"), + ])), + + Tool(name: "update_note", + description: "Change a note's title, colour, material, window flags, or completed-task display, and bring it forward (open) or hide it. Only fields you pass change.", + inputSchema: object([ + "note_id": prop("string", "The note's id"), + "title": prop("string", "New title"), + "color": enumProp(NoteColor.allCases.map(\.rawValue), "New colour"), + "material": enumProp(["solid", "glass"], "New background style"), + "float_on_top": prop("boolean", "Keep above other windows"), + "show_on_all_spaces": prop("boolean", "Show on every Space"), + "collapsed": prop("boolean", "Roll up to the title bar"), + "hide_completed": prop("boolean", "Hide checked-off tasks"), + "move_completed_to_bottom": prop("boolean", "Sink checked tasks to the bottom"), + "open": prop("boolean", "true surfaces the note (above other apps), false hides it"), + "focus": prop("boolean", "Bring Tic to the front and focus the note (stronger than open)"), + ], required: ["note_id"])), + + Tool(name: "move_note", + description: "Move or resize a note's window. Coordinates are global desktop points (origin bottom-left), matching how Stickies stores them.", + inputSchema: object([ + "note_id": prop("string", "The note's id"), + "x": prop("number", "Left edge"), "y": prop("number", "Bottom edge"), + "width": prop("number", "Width"), "height": prop("number", "Height"), + ], required: ["note_id"])), + + Tool(name: "focus_note", + description: "Bring a note to the front and focus it (activates Tic, makes it the key window). Use to draw attention; plain writes surface a note without stealing focus.", + inputSchema: object(["note_id": prop("string", "The note's id")], required: ["note_id"])), + + Tool(name: "delete_note", + description: "Permanently delete a note and all its tasks. Cannot be undone.", + inputSchema: object(["note_id": prop("string", "The note's id")], required: ["note_id"]), + annotations: destructive), + + Tool(name: "add_tasks", + description: "Append tasks to a note. Each task is a string, or an object {text, level} where level 0-2 sets nesting to build sub-items. Levels are clamped to a valid outline.", + inputSchema: object([ + "note_id": prop("string", "The note's id"), + "tasks": taskArraySchema, + ], required: ["note_id", "tasks"])), + + Tool(name: "update_task", + description: "Edit a task's text and/or tick it done. Ticking a parent ticks its subtree; finishing the last child auto-ticks the parent.", + inputSchema: object([ + "task_id": prop("string", "The task's id"), + "text": prop("string", "New text (must not be blank)"), + "done": prop("boolean", "Mark done or not done"), + ], required: ["task_id"])), + + Tool(name: "move_task", + description: "Reorder a task (and its subtree) and/or re-nest it. to_index is where it lands among the note's tasks; level 0-2 sets its new depth. Tick states are preserved.", + inputSchema: object([ + "task_id": prop("string", "The task's id"), + "to_index": prop("integer", "Target position among the note's tasks (0-based)"), + "level": prop("integer", "New indent level 0-2"), + ], required: ["task_id"])), + + Tool(name: "delete_tasks", + description: "Delete one or more tasks by id. Their sub-tasks are kept and re-levelled. Cannot be undone.", + inputSchema: object(["task_ids": .object(["type": "array", "items": prop("string", "A task id"), "description": "Task ids to delete"])], required: ["task_ids"]), + annotations: destructive), + + Tool(name: "clear_completed", + description: "Remove every checked-off task in a note. Cannot be undone.", + inputSchema: object(["note_id": prop("string", "The note's id")], required: ["note_id"]), + annotations: destructive), + + Tool(name: "set_task_image", + description: "Attach an image to a task (replacing any it has). Provide the image as base64 PNG or JPEG (a data: URL is also accepted). Max 10 MB.", + inputSchema: object([ + "task_id": prop("string", "The task's id"), + "data": prop("string", "Base64-encoded PNG or JPEG"), + ], required: ["task_id", "data"])), + + Tool(name: "crop_task_image", + description: "Crop a task's image. The rect is fractions 0-1 of the image with a top-left origin; it's clamped to stay in bounds. The original is kept, so cropping is reversible.", + inputSchema: object([ + "task_id": prop("string", "The task's id"), + "x": prop("number", "Left, 0-1"), "y": prop("number", "Top, 0-1"), + "width": prop("number", "Width, 0-1"), "height": prop("number", "Height, 0-1"), + ], required: ["task_id", "x", "y", "width", "height"])), + + Tool(name: "remove_task_image", + description: "Remove a task's image. If the task has no text, the task itself is removed too (an image-only task).", + inputSchema: object(["task_id": prop("string", "The task's id")], required: ["task_id"]), + annotations: destructive), + ] + } + + private func object(_ props: [String: Value], required: [String] = []) -> Value { + var o: [String: Value] = ["type": "object"] + if !props.isEmpty { o["properties"] = .object(props) } + if !required.isEmpty { o["required"] = .array(required.map { .string($0) }) } + return .object(o) + } + + private func prop(_ type: String, _ description: String) -> Value { + .object(["type": .string(type), "description": .string(description)]) + } + + private func enumProp(_ cases: [String], _ description: String) -> Value { + .object(["type": "string", "enum": .array(cases.map { .string($0) }), "description": .string(description)]) + } + + private var taskArraySchema: Value { + .object([ + "type": "array", + "description": "Tasks — each a string, or {text, level} where level is 0 (top), 1, or 2.", + "items": .object(["type": .array(["string", "object"])]), + ]) + } +} diff --git a/Sources/Tic/MCP/MCPTools.swift b/Sources/Tic/MCP/MCPTools.swift new file mode 100644 index 0000000..157e1d1 --- /dev/null +++ b/Sources/Tic/MCP/MCPTools.swift @@ -0,0 +1,426 @@ +import CoreGraphics +import Foundation +import MCP + +/// The MCP tool surface: everything a person can do to notes and tasks, over `AppDatabase`. Pure +/// data + three window pokes (`WindowActions`); the existing GRDB observers carry every write into +/// the open panels, so "live update" needs nothing here. Reuses `TaskOutline` for every structural +/// rule (level clamp, completion cascade, normalise) so tools and the UI can never diverge. +struct MCPTools: Sendable { + let database: AppDatabase + let window: WindowActions + + /// The only reach into AppKit: bring a note's panel up, hide it, or move it. Set by `AppModel` + /// so this stays AppKit-free (mirrors how `NoteController` uses closures). + struct WindowActions: Sendable { + var open: @Sendable (UUID) async -> Void = { _ in } + var close: @Sendable (UUID) async -> Void = { _ in } + var setFrame: @Sendable (UUID, CGRect) async -> Void = { _, _ in } + var focus: @Sendable (UUID) async -> Void = { _ in } + } + + private struct ToolError: Error { let message: String; init(_ m: String) { message = m } } + + private static let maxTasksPerCall = 200 + private static let maxTextLength = 20_000 + private static let maxImageBytes = 10 * 1024 * 1024 + + // MARK: - Dispatch + + func call(_ name: String, _ arguments: [String: Value]?) async -> CallTool.Result { + let args = Args(arguments) + do { + let value: Value + switch name { + case "list_notes": value = try await listNotes() + case "get_note": value = try await getNote(args) + case "create_note": value = try await createNote(args) + case "update_note": value = try await updateNote(args) + case "move_note": value = try await moveNote(args) + case "delete_note": value = try await deleteNote(args) + case "add_tasks": value = try await addTasks(args) + case "update_task": value = try await updateTask(args) + case "move_task": value = try await moveTask(args) + case "delete_tasks": value = try await deleteTasks(args) + case "clear_completed": value = try await clearCompleted(args) + case "set_task_image": value = try await setTaskImage(args) + case "crop_task_image": value = try await cropTaskImage(args) + case "remove_task_image": value = try await removeTaskImage(args) + case "focus_note": value = try await focusNote(args) + default: throw ToolError("Unknown tool: \(name)") + } + return CallTool.Result(content: [.text(json(value))], isError: false) + } catch let error as ToolError { + return CallTool.Result(content: [.text(error.message)], isError: true) + } catch { + return CallTool.Result(content: [.text("Tic error: \(error.localizedDescription)")], isError: true) + } + } + + // MARK: - Note tools + + private func listNotes() async throws -> Value { + let notes = try await database.allNotes() + return .array(notes.map { n in + .object([ + "id": .string(n.id.uuidString), + "title": .string(n.title), + "color": .string(n.color.rawValue), + "is_open": .bool(n.isOpen), + ]) + }) + } + + private func getNote(_ args: Args) async throws -> Value { + let id = try args.uuid("note_id") + let note = try await note(id) + let tasks = try await database.tasks(noteId: id) + let imageIds = try await database.taskImageIds(noteId: id) + return noteValue(note, tasks: tasks, imageIds: imageIds) + } + + private func createNote(_ args: Args) async throws -> Value { + let stored = try await database.insertNewNote(Note( + title: (args.string("title") ?? "").trimmed.capped, + color: try args.string("color").map(color) ?? .yellow, + material: try args.string("material").map(material) ?? .solid, + floatOnTop: args.bool("float_on_top") ?? false, + showOnAllSpaces: args.bool("show_on_all_spaces") ?? false, + isCollapsed: args.bool("collapsed") ?? false + )) + + if let frame = args.frame() { + try await database.updateNoteFrame( + id: stored.id, x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height) + } else { + // The cascade `NoteWindowManager.newNote` uses, so an agent's note doesn't stack dead-on. + let step = Double(stored.sortIndex % 8) * 28 + try await database.updateNoteFrame( + id: stored.id, x: 180 + step, y: 320 - step, width: stored.frameW, height: stored.frameH) + } + + if let items = args.array("tasks") { + for task in try buildTasks(items, noteId: stored.id, existing: []) { + try await database.insertTask(task) + } + } + + if args.bool("focus") == true { await window.focus(stored.id) } else { await window.open(stored.id) } + let tasks = try await database.tasks(noteId: stored.id) + return noteValue(stored, tasks: tasks) + } + + private func updateNote(_ args: Args) async throws -> Value { + let id = try args.uuid("note_id") + let current = try await note(id) + + if let title = args.string("title") { + try await database.updateNoteTitle(id: id, title: title.trimmed.capped) + } + + let newColor = try args.string("color").map(color) ?? current.color + let newMaterial = try args.string("material").map(material) ?? current.material + if newColor != current.color || newMaterial != current.material { + try await database.updateNoteAppearance(id: id, color: newColor, material: newMaterial) + } + + let float = args.bool("float_on_top") ?? current.floatOnTop + let allSpaces = args.bool("show_on_all_spaces") ?? current.showOnAllSpaces + let collapsed = args.bool("collapsed") ?? current.isCollapsed + if float != current.floatOnTop || allSpaces != current.showOnAllSpaces || collapsed != current.isCollapsed { + try await database.updateNoteFlags(id: id, floatOnTop: float, showOnAllSpaces: allSpaces, isCollapsed: collapsed) + } + + let hide = args.bool("hide_completed") ?? current.hideCompleted + let move = args.bool("move_completed_to_bottom") ?? current.moveCompletedToBottom + if hide != current.hideCompleted || move != current.moveCompletedToBottom { + try await database.updateNoteListOptions(id: id, hideCompleted: hide, moveCompletedToBottom: move) + } + + if args.bool("focus") == true { + await window.focus(id) + } else if let open = args.bool("open") { + if open { await window.open(id) } else { await window.close(id) } + } + + return noteValue(try await note(id), tasks: nil) + } + + private func moveNote(_ args: Args) async throws -> Value { + let id = try args.uuid("note_id") + let n = try await note(id) + let rect = CGRect( + x: args.double("x") ?? n.frameX, + y: args.double("y") ?? n.frameY, + width: max(120, args.double("width") ?? n.frameW), + height: max(80, args.double("height") ?? n.frameH) + ) + await window.setFrame(id, rect) + return .object(["frame": frameValue(rect)]) + } + + private func deleteNote(_ args: Args) async throws -> Value { + let id = try args.uuid("note_id") + _ = try await note(id) + await window.close(id) + try await database.deleteNote(id: id) // tasks + images cascade + return .object(["deleted": .string(id.uuidString)]) + } + + // MARK: - Task tools + + private func addTasks(_ args: Args) async throws -> Value { + let id = try args.uuid("note_id") + _ = try await note(id) + guard let items = args.array("tasks"), !items.isEmpty else { throw ToolError("'tasks' is required") } + let existing = try await database.tasks(noteId: id) + let built = try buildTasks(items, noteId: id, existing: existing) + guard !built.isEmpty else { throw ToolError("No non-empty tasks given") } + for task in built { try await database.insertTask(task) } + return .object(["added": .int(built.count), "note_id": .string(id.uuidString)]) + } + + private func updateTask(_ args: Args) async throws -> Value { + let id = try args.uuid("task_id") + guard let task = try await database.task(id: id) else { throw ToolError("No task with id \(id.uuidString)") } + var changed: [String] = [] + + if let text = args.string("text") { + let trimmed = text.trimmed + guard !trimmed.isEmpty else { throw ToolError("Task text can't be blank — use delete_tasks to remove it") } + if trimmed != task.text { + var updated = task + updated.text = trimmed.capped + try await database.update(updated) + changed.append("text") + } + } + + if let done = args.bool("done"), done != task.isDone { + // Route completion through the same cascade a checkbox uses (ticks the subtree, bubbles up). + let all = try await database.tasks(noteId: task.noteId) + let toggled = TaskOutline.applyingToggle(all, toggling: id, now: Date()) + try await database.updateTaskCompletion(TaskOutline.completionChanges(from: all, to: toggled)) + changed.append("done") + } + + return .object(["task_id": .string(id.uuidString), "changed": .array(changed.map { .string($0) })]) + } + + private func moveTask(_ args: Args) async throws -> Value { + let id = try args.uuid("task_id") + guard let task = try await database.task(id: id) else { throw ToolError("No task with id \(id.uuidString)") } + let all = try await database.tasks(noteId: task.noteId) + guard let currentIndex = all.firstIndex(where: { $0.id == id }) else { throw ToolError("Task not in its note") } + + let toIndex = min(max(args.int("to_index") ?? currentIndex, 0), all.count) + let level = args.int("level").map { min(max($0, 0), TaskItem.maxIndentLevel) } + let reordered = TaskOutline.movingSubtree(all, id: id, toInsertionIndex: toIndex, targetLevel: level) + guard reordered != all else { return .object(["task_id": .string(id.uuidString), "moved": .bool(false)]) } + + let normalized = TaskOutline.normalizedLevels(reordered) + let levels = TaskOutline.indentLevelChanges(from: all, to: normalized).map { TaskLevelUpdate(id: $0.id, level: $0.level) } + try await database.applyStructuralUpdate(deleteIds: [], reorder: normalized, levels: levels) + return .object(["task_id": .string(id.uuidString), "moved": .bool(true)]) + } + + private func deleteTasks(_ args: Args) async throws -> Value { + let ids = try args.uuidArray("task_ids") + guard let first = try await database.task(id: ids[0]) else { throw ToolError("No task with id \(ids[0].uuidString)") } + let all = try await database.tasks(noteId: first.noteId) + let idSet = Set(ids) + let deleteIds = all.filter { idSet.contains($0.id) }.map(\.id) + guard !deleteIds.isEmpty else { throw ToolError("None of those ids are in the note") } + // Deleting keeps a row's subtasks (like the UI); survivors are renormalised so nothing orphans. + let survivors = TaskOutline.normalizedLevels(all.filter { !idSet.contains($0.id) }) + let levels = TaskOutline.indentLevelChanges(from: all, to: survivors).map { TaskLevelUpdate(id: $0.id, level: $0.level) } + try await database.applyStructuralUpdate(deleteIds: deleteIds, reorder: survivors, levels: levels) + return .object(["deleted": .int(deleteIds.count)]) + } + + private func clearCompleted(_ args: Args) async throws -> Value { + let id = try args.uuid("note_id") + let all = try await database.tasks(noteId: id) + let doneIds = all.filter(\.isDone).map(\.id) + guard !doneIds.isEmpty else { return .object(["cleared": .int(0)]) } + let survivors = TaskOutline.normalizedLevels(all.filter { !$0.isDone }) + let levels = TaskOutline.indentLevelChanges(from: all, to: survivors).map { TaskLevelUpdate(id: $0.id, level: $0.level) } + try await database.applyStructuralUpdate(deleteIds: doneIds, reorder: survivors, levels: levels) + return .object(["cleared": .int(doneIds.count)]) + } + + private func focusNote(_ args: Args) async throws -> Value { + let id = try args.uuid("note_id") + _ = try await note(id) + await window.focus(id) // opens if needed, activates Tic, makes the note key + return .object(["focused": .string(id.uuidString)]) + } + + // MARK: - Image tools + + private func setTaskImage(_ args: Args) async throws -> Value { + let id = try args.uuid("task_id") + guard try await database.task(id: id) != nil else { throw ToolError("No task with id \(id.uuidString)") } + guard let encoded = args.string("data") else { throw ToolError("'data' (base64 PNG/JPEG) is required") } + // Accept a bare base64 string or a data: URL. + let base64 = encoded.hasPrefix("data:") ? String(encoded.drop(while: { $0 != "," }).dropFirst()) : encoded + guard let raw = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else { + throw ToolError("'data' is not valid base64") + } + guard raw.count <= Self.maxImageBytes else { throw ToolError("Image too large (max 10 MB)") } + guard let normalized = TaskImage.normalizedData(raw) else { throw ToolError("'data' isn't a decodable image (PNG or JPEG)") } + try await database.setTaskImage(taskId: id, data: normalized) // replacing resets the crop to full + return .object(["task_id": .string(id.uuidString), "bytes": .int(normalized.count)]) + } + + private func cropTaskImage(_ args: Args) async throws -> Value { + let id = try args.uuid("task_id") + guard try await database.hasImage(taskId: id) else { throw ToolError("Task \(id.uuidString) has no image to crop") } + guard let x = args.double("x"), let y = args.double("y"), + let w = args.double("width"), let h = args.double("height") else { + throw ToolError("Crop needs x, y, width, height as fractions 0…1 with a top-left origin") + } + // Clamp to an in-bounds rect no smaller than the minimum side. + let cw = min(max(w, TaskImage.minCropSide), 1) + let ch = min(max(h, TaskImage.minCropSide), 1) + let cx = min(max(x, 0), 1 - cw) + let cy = min(max(y, 0), 1 - ch) + let crop = CGRect(x: cx, y: cy, width: cw, height: ch) + try await database.updateTaskImageCrop(taskId: id, crop: crop) + return .object(["task_id": .string(id.uuidString), "crop": frameValue(crop)]) + } + + private func removeTaskImage(_ args: Args) async throws -> Value { + let id = try args.uuid("task_id") + guard let task = try await database.task(id: id) else { throw ToolError("No task with id \(id.uuidString)") } + guard try await database.hasImage(taskId: id) else { throw ToolError("Task has no image") } + if task.text.trimmed.isEmpty { + // An image-only task has nothing left once the image goes — delete the task (image cascades), + // exactly like the UI's remove-image. + let all = try await database.tasks(noteId: task.noteId) + let survivors = TaskOutline.normalizedLevels(all.filter { $0.id != id }) + let levels = TaskOutline.indentLevelChanges(from: all, to: survivors).map { TaskLevelUpdate(id: $0.id, level: $0.level) } + try await database.applyStructuralUpdate(deleteIds: [id], reorder: survivors, levels: levels) + return .object(["removed_image": .bool(true), "deleted_task": .bool(true)]) + } + try await database.deleteTaskImage(taskId: id) + return .object(["removed_image": .bool(true), "deleted_task": .bool(false)]) + } + + // MARK: - Helpers + + private func note(_ id: UUID) async throws -> Note { + guard let note = try await database.note(id: id) else { throw ToolError("No note with id \(id.uuidString)") } + return note + } + + /// Parses `tasks` (each a string, or `{text, level}`) into new `TaskItem`s appended after + /// `existing`, with levels clamped and normalised so the appended block is always a valid outline. + private func buildTasks(_ items: [Value], noteId: UUID, existing: [TaskItem]) throws -> [TaskItem] { + guard items.count <= Self.maxTasksPerCall else { throw ToolError("Too many tasks in one call (max \(Self.maxTasksPerCall))") } + var built: [TaskItem] = [] + for item in items { + let text: String + let level: Int + if let s = item.stringValue { text = s.trimmed; level = 0 } + else if let o = item.objectValue { + text = (o["text"]?.stringValue ?? "").trimmed + level = (o["level"].flatMap { $0.intValue ?? $0.doubleValue.map(Int.init) }) ?? 0 + } else { throw ToolError("Each task must be a string or an object {text, level}") } + guard !text.isEmpty else { continue } + built.append(TaskItem(noteId: noteId, text: text.capped, indentLevel: min(max(level, 0), TaskItem.maxIndentLevel))) + } + guard !built.isEmpty else { return [] } + let normalized = TaskOutline.normalizedLevels(existing + built) + return Array(normalized.suffix(built.count)) + } + + private func color(_ raw: String) throws -> NoteColor { + guard let c = NoteColor(rawValue: raw.lowercased()) else { + throw ToolError("Unknown color '\(raw)'. Options: \(NoteColor.allCases.map(\.rawValue).joined(separator: ", "))") + } + return c + } + + private func material(_ raw: String) throws -> NoteMaterial { + guard let m = NoteMaterial(rawValue: raw.lowercased()) else { + throw ToolError("Unknown material '\(raw)'. Options: solid, glass") + } + return m + } + + private func noteValue(_ n: Note, tasks: [TaskItem]?, imageIds: Set = []) -> Value { + var o: [String: Value] = [ + "id": .string(n.id.uuidString), + "title": .string(n.title), + "color": .string(n.color.rawValue), + "material": .string(n.material.rawValue), + "is_open": .bool(n.isOpen), + "float_on_top": .bool(n.floatOnTop), + "show_on_all_spaces": .bool(n.showOnAllSpaces), + "collapsed": .bool(n.isCollapsed), + "hide_completed": .bool(n.hideCompleted), + "move_completed_to_bottom": .bool(n.moveCompletedToBottom), + "frame": frameValue(CGRect(x: n.frameX, y: n.frameY, width: n.frameW, height: n.frameH)), + ] + if let tasks { + o["tasks"] = .array(tasks.map { t in + .object([ + "id": .string(t.id.uuidString), + "text": .string(t.text), + "level": .int(t.indentLevel), + "done": .bool(t.isDone), + "has_image": .bool(imageIds.contains(t.id)), + ]) + }) + } + return .object(o) + } + + private func frameValue(_ r: CGRect) -> Value { + .object(["x": .double(r.origin.x), "y": .double(r.origin.y), "width": .double(r.width), "height": .double(r.height)]) + } + + private func json(_ value: Value) -> String { + guard let data = try? JSONEncoder().encode(value) else { return "{}" } + return String(decoding: data, as: UTF8.self) + } + + // MARK: - Argument access + + private struct Args { + let raw: [String: Value] + init(_ a: [String: Value]?) { raw = a ?? [:] } + + func string(_ k: String) -> String? { raw[k]?.stringValue } + func bool(_ k: String) -> Bool? { raw[k]?.boolValue } + func array(_ k: String) -> [Value]? { raw[k]?.arrayValue } + func int(_ k: String) -> Int? { raw[k].flatMap { $0.intValue ?? $0.doubleValue.map(Int.init) } } + func double(_ k: String) -> Double? { raw[k].flatMap { $0.doubleValue ?? $0.intValue.map(Double.init) } } + + func uuid(_ k: String) throws -> UUID { + guard let s = raw[k]?.stringValue else { throw ToolError("Missing '\(k)'") } + guard let id = UUID(uuidString: s) else { throw ToolError("'\(k)' is not a valid id: \(s)") } + return id + } + + func uuidArray(_ k: String) throws -> [UUID] { + guard let arr = raw[k]?.arrayValue, !arr.isEmpty else { throw ToolError("'\(k)' is required") } + return try arr.map { v in + guard let s = v.stringValue, let id = UUID(uuidString: s) else { throw ToolError("'\(k)' has an invalid id") } + return id + } + } + + func frame() -> CGRect? { + guard let x = double("x"), let y = double("y") else { return nil } + return CGRect(x: x, y: y, width: double("width") ?? 280, height: double("height") ?? 360) + } + } + +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } + var capped: String { count > 20_000 ? String(prefix(20_000)) : self } +} diff --git a/Sources/Tic/Models/TaskOutline.swift b/Sources/Tic/Models/TaskOutline.swift index 337d4c9..5fc8ebd 100644 --- a/Sources/Tic/Models/TaskOutline.swift +++ b/Sources/Tic/Models/TaskOutline.swift @@ -174,6 +174,15 @@ enum TaskOutline { /// The `indentLevel` changes needed to turn `old` into `new`, matched **by id** (not position). /// Matching by id is what lets a reorder that also re-nests persist correctly: the new list is in /// a different order than the old one, so a positional diff would miss the level change. + /// The `isDone`/`completedAt` deltas between two same-ordered task lists — the rows a + /// completion write must touch. Shared by the controller (a checkbox) and the MCP tools. + static func completionChanges(from before: [TaskItem], to after: [TaskItem]) -> [TaskCompletionUpdate] { + zip(before, after).compactMap { b, a in + guard b.isDone != a.isDone || b.completedAt != a.completedAt else { return nil } + return TaskCompletionUpdate(id: a.id, isDone: a.isDone, completedAt: a.completedAt) + } + } + static func indentLevelChanges(from old: [TaskItem], to new: [TaskItem]) -> [(id: UUID, level: Int)] { let oldLevel = Dictionary(uniqueKeysWithValues: old.map { ($0.id, $0.indentLevel) }) return new.compactMap { task in diff --git a/Sources/Tic/TicApp.swift b/Sources/Tic/TicApp.swift index 71bd8cf..58cdf09 100644 --- a/Sources/Tic/TicApp.swift +++ b/Sources/Tic/TicApp.swift @@ -1,7 +1,6 @@ import SwiftUI import AppKit -@main struct TicApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @@ -116,6 +115,8 @@ private struct MenuBarContent: View { Button("Search Lists…") { model.openSearch() } + Button("AI Agents (MCP)…") { model.openMCPSetup() } + Toggle("Launch at Login", isOn: Binding( get: { model.launchAtLogin }, set: { model.setLaunchAtLogin($0) } diff --git a/Sources/Tic/Views/MCPSetupView.swift b/Sources/Tic/Views/MCPSetupView.swift new file mode 100644 index 0000000..fbf852f --- /dev/null +++ b/Sources/Tic/Views/MCPSetupView.swift @@ -0,0 +1,159 @@ +import AppKit +import SwiftUI + +/// The "AI Agents (MCP)" setup window: a master toggle with live status on top, a client list on the +/// left, and the selected client's copy-paste install snippet on the right. Built like the Lists +/// palette (an NSPanel hosting SwiftUI, owned by `AppModel`). All install snippets spawn the same +/// `Tic --mcp`, so this is really just presenting one command nine ways. +struct MCPSetupView: View { + @State private var model = AppModel.shared + @State private var selection: String + @State private var copied = false + + private let executablePath: String + private let clients: [MCPClient] + private let inApplications: Bool + + init() { + let exe = Bundle.main.executableURL?.path ?? "Tic" + executablePath = exe + clients = MCPClients.all(executablePath: exe) + inApplications = (Bundle.main.bundleURL.path.hasPrefix("/Applications")) + _selection = State(initialValue: clients.first?.id ?? "") + } + + private var selectedClient: MCPClient { + clients.first { $0.id == selection } ?? clients[0] + } + + var body: some View { + VStack(spacing: 0) { + header + Divider().opacity(0.5) + HStack(spacing: 0) { + clientList + Divider().opacity(0.5) + detail + } + } + .frame(width: 680, height: 460) + .background(.regularMaterial) + .onExitCommand { model.dismissMCPSetup() } + } + + // MARK: - Header: title, toggle, status + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 3) { + Text("AI Agents (MCP)").font(.system(size: 17, weight: .semibold)) + Text("Let Claude, Cursor and friends draft and tick your lists.") + .font(.callout).foregroundStyle(.secondary) + } + Spacer() + Button { model.dismissMCPSetup() } label: { + Image(systemName: "xmark.circle.fill").font(.system(size: 15)).foregroundStyle(.tertiary) + } + .buttonStyle(.plain).help("Close (Esc)") + } + HStack(spacing: 12) { + Toggle("Enable MCP server", isOn: Binding( + get: { model.mcpEnabled }, + set: { model.setMCPEnabled($0) } + )) + .toggleStyle(.switch) + Spacer() + statusBadge + } + } + .padding(18) + } + + private var statusBadge: some View { + HStack(spacing: 6) { + Circle().fill(model.mcpEnabled ? Color.green : Color.secondary).frame(width: 8, height: 8) + Text(statusText).font(.callout).foregroundStyle(.secondary) + } + } + + private var statusText: String { + guard model.mcpEnabled else { return "Off" } + let n = model.mcpConnections + return n == 0 ? "Running · no agents connected" : "Running · \(n) connected" + } + + // MARK: - Client list + + private var clientList: some View { + List(clients, selection: $selection) { client in + Text(client.name).tag(client.id) + } + .listStyle(.sidebar) + .frame(width: 190) + } + + // MARK: - Detail: snippet + actions + + private var detail: some View { + VStack(alignment: .leading, spacing: 12) { + Text(selectedClient.detail).font(.callout).foregroundStyle(.secondary) + + ScrollView { + Text(selectedClient.snippet) + .font(.system(size: 12, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) + .frame(maxHeight: .infinity) + + HStack(spacing: 10) { + if let link = selectedClient.deeplink { + Button { open(link) } label: { Label("Add to \(selectedClient.name)", systemImage: "arrow.down.app") } + .buttonStyle(.borderedProminent) + } + Button { copy() } label: { + Label(copied ? "Copied" : "Copy", systemImage: copied ? "checkmark" : "doc.on.doc") + } + if let path = selectedClient.configPath { + Button { openConfig(path) } label: { Label("Open config file", systemImage: "folder") } + .buttonStyle(.bordered) + } + Spacer() + } + + if !inApplications { + Label("Tic isn't in /Applications — this command's path changes if you move the app.", + systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(.secondary) + } + } + .padding(18) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onChange(of: selection) { _, _ in copied = false } + } + + private func copy() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(selectedClient.snippet, forType: .string) + copied = true + } + + /// Opens a client install deeplink (Cursor / VS Code). + private func open(_ link: String) { + if let url = URL(string: link) { NSWorkspace.shared.open(url) } + } + + /// Opens the client's config file, or reveals its folder in Finder if the file doesn't exist yet. + private func openConfig(_ path: String) { + let expanded = (path as NSString).expandingTildeInPath + let url = URL(fileURLWithPath: expanded) + if FileManager.default.fileExists(atPath: expanded) { + NSWorkspace.shared.open(url) + } else { + NSWorkspace.shared.activateFileViewerSelecting([url.deletingLastPathComponent()]) + } + } +} diff --git a/Sources/Tic/Views/NoteView.swift b/Sources/Tic/Views/NoteView.swift index e359d55..01ab4f3 100644 --- a/Sources/Tic/Views/NoteView.swift +++ b/Sources/Tic/Views/NoteView.swift @@ -177,6 +177,10 @@ struct NoteView: View { guard let newID else { return } withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo(newID, anchor: .bottom) } } + .onChange(of: controller.note.title) { _, newTitle in + // An agent (or another writer) renamed the note: reflect it unless the user is mid-edit. + if !titleFocused { titleText = newTitle } + } .overlay(alignment: .top) { if display.isEmpty { Text(controller.tasks.isEmpty diff --git a/Sources/Tic/Windows/NoteWindowManager.swift b/Sources/Tic/Windows/NoteWindowManager.swift index 483b934..726d224 100644 --- a/Sources/Tic/Windows/NoteWindowManager.swift +++ b/Sources/Tic/Windows/NoteWindowManager.swift @@ -238,6 +238,59 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { var openCount: Int { panels.count } + // MARK: - Actions for external writers (MCP) + + /// Surfaces a note's panel, opening it from the DB if it wasn't on screen. Used when an agent + /// creates a note or writes to a closed one — so the user sees the work. Uses + /// `orderFrontRegardless` so a `.normal`-level note appears above other apps even while Tic is in + /// the background, but does **not** activate Tic — the user's keyboard focus stays where it was. + func openNoteByID(_ id: UUID) async { + if let panel = panels[id] { panel.orderFrontRegardless(); return } + guard let note = try? await appDatabase.note(id: id) else { return } + let panel = openNote(note, makeKey: false) + panel.orderFrontRegardless() + } + + /// Brings a note fully to the foreground: activates Tic and makes the note key, so it lands in + /// front and ready to type in. The explicit "jump to this note" (the `focus_note` MCP tool), + /// stronger than `openNoteByID` — this one does take focus. + func focusNoteByID(_ id: UUID) async { + let panel: NotePanel + if let existing = panels[id] { + panel = existing + } else if let note = try? await appDatabase.note(id: id) { + panel = openNote(note, makeKey: false) + } else { + return + } + // A background app's plain activate() is refused by macOS focus-stealing prevention, so use + // the assertive form (deprecated on 14 but still the only thing that reliably pulls focus + // forward from another app). This is opt-in (focus_note / focus:true), never a plain write. + NSApp.activate(ignoringOtherApps: true) + panel.makeKeyAndOrderFront(nil) + panel.orderFrontRegardless() + } + + /// Hides a note's panel (the agent equivalent of the header X): marks it closed and closes the + /// window if it's open. A no-op beyond the DB write when nothing is on screen. + func closeNoteByID(_ id: UUID) async { + try? await appDatabase.updateNoteOpen(id: id, isOpen: false) + panels[id]?.close() // → windowWillClose tears down the controller/panel + } + + /// Moves/resizes a note. If its panel is live, set the frame (the debounced save persists it, + /// exactly like a user drag); otherwise persist the frame so `place` uses it when next opened. + func setFrame(_ id: UUID, to rect: CGRect) async { + if let panel = panels[id] { + panel.setFrame(rect, display: true, animate: false) // fires didMove/didResize → save + } else { + savedFrames[id] = rect + try? await appDatabase.updateNoteFrame( + id: id, x: rect.origin.x, y: rect.origin.y, width: rect.width, height: rect.height + ) + } + } + // MARK: - NSWindowDelegate func windowDidMove(_ notification: Notification) { scheduleFrameSave(notification) } diff --git a/Sources/Tic/main.swift b/Sources/Tic/main.swift new file mode 100644 index 0000000..2e4b602 --- /dev/null +++ b/Sources/Tic/main.swift @@ -0,0 +1,9 @@ +import Foundation + +// `Tic --mcp` is the stdio bridge that MCP clients spawn (see `MCPProxy`); anything else is the app. +// A `main.swift` instead of `@main` so the flag is checked before AppKit ever starts. +if CommandLine.arguments.dropFirst().contains("--mcp") { + MCPProxy.run() +} else { + TicApp.main() +} diff --git a/Tests/TicTests/MCPClientsTests.swift b/Tests/TicTests/MCPClientsTests.swift new file mode 100644 index 0000000..7a86172 --- /dev/null +++ b/Tests/TicTests/MCPClientsTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import Tic + +@Suite("MCP client instructions") +struct MCPClientsTests { + @Test("every client embeds the live executable path and launches with --mcp") + func snippetsCarryTheExecutable() { + let exe = "/Applications/Tic.app/Contents/MacOS/Tic" + let clients = MCPClients.all(executablePath: exe) + #expect(clients.contains { $0.name == "Claude Desktop" }) + #expect(clients.contains { $0.name == "Claude Code" }) + for client in clients { + #expect(client.snippet.contains(exe), "\(client.name) is missing the executable path") + #expect(client.snippet.contains("--mcp"), "\(client.name) is missing the --mcp flag") + } + } + + @Test("file-based clients name a config path; command-based ones don't") + func installKinds() { + let clients = MCPClients.all(executablePath: "/x/Tic") + let claudeDesktop = clients.first { $0.name == "Claude Desktop" } + #expect(claudeDesktop?.configPath?.contains("claude_desktop_config.json") == true) + let claudeCode = clients.first { $0.name == "Claude Code" } + #expect(claudeCode?.configPath == nil) // CLI install + #expect(claudeCode?.snippet.hasPrefix("claude mcp add tic") == true) + } + + @Test("Cursor and VS Code offer install deeplinks; file clients don't") + func deeplinks() { + let clients = MCPClients.all(executablePath: "/Applications/Tic.app/Contents/MacOS/Tic") + let cursor = clients.first { $0.name == "Cursor" } + #expect(cursor?.deeplink?.hasPrefix("cursor://") == true) + #expect(cursor?.deeplink?.contains("config=") == true) + let vscode = clients.first { $0.name == "VS Code" } + #expect(vscode?.deeplink?.hasPrefix("vscode:mcp/install?") == true) + #expect(clients.first { $0.name == "Claude Desktop" }?.deeplink == nil) + } +} diff --git a/Tests/TicTests/MCPServiceTests.swift b/Tests/TicTests/MCPServiceTests.swift new file mode 100644 index 0000000..7b322eb --- /dev/null +++ b/Tests/TicTests/MCPServiceTests.swift @@ -0,0 +1,48 @@ +import Foundation +import MCP +import Network +import Testing +@testable import Tic + +/// The socket end to end: an SDK `Client` connects to `MCPService` over the Unix socket, completes +/// the handshake, lists tools, and its disconnect is noticed (the session count drops). +@Suite("MCP service") +struct MCPServiceTests { + @Test("a client completes the handshake over the Unix socket, and leaving is noticed") + func handshakeOverSocket() async throws { + let path = NSTemporaryDirectory() + "tic-\(UUID().uuidString.prefix(8)).sock" + let service = MCPService(database: try AppDatabase.makeInMemory(), socketPath: path) + try service.start() + defer { service.stop() } + + let client = Client(name: "test", version: "0") + let transport = NetworkTransport( + connection: NWConnection(to: .unix(path: path), using: .tcp), + heartbeatConfig: .init(enabled: false), + reconnectionConfig: .init(enabled: false) + ) + let handshake = try await client.connect(transport: transport) + #expect(handshake.serverInfo.name == "Tic") + #expect(service.connectionCount == 1) + + let listed = try await client.listTools() + #expect(listed.tools.contains { $0.name == "create_note" }) + #expect(listed.tools.contains { $0.name == "get_note" }) + + await client.disconnect() + for _ in 0..<40 where service.connectionCount != 0 { // EOF reaches the server asynchronously + try await Task.sleep(for: .milliseconds(50)) + } + #expect(service.connectionCount == 0) + } + + @Test("stop removes the socket so a proxy fails fast") + func stopRemovesSocket() throws { + let path = NSTemporaryDirectory() + "tic-\(UUID().uuidString.prefix(8)).sock" + let service = MCPService(database: try AppDatabase.makeInMemory(), socketPath: path) + try service.start() + #expect(FileManager.default.fileExists(atPath: path)) + service.stop() + #expect(!FileManager.default.fileExists(atPath: path)) + } +} diff --git a/Tests/TicTests/MCPToolsTests.swift b/Tests/TicTests/MCPToolsTests.swift new file mode 100644 index 0000000..f6507ec --- /dev/null +++ b/Tests/TicTests/MCPToolsTests.swift @@ -0,0 +1,229 @@ +import CoreGraphics +import Foundation +import MCP +import Testing +@testable import Tic + +/// The MCP tool dispatcher end to end over an in-memory database — the same path a real agent drives, +/// minus the socket. Window pokes are captured so we can assert the tools ask for the right ones. +@Suite("MCP tools") +struct MCPToolsTests { + /// A tools instance plus a record of every window action it requested. + @MainActor + final class Harness { + let db: AppDatabase + let tools: MCPTools + var opened: [UUID] = [] + var closed: [UUID] = [] + var framed: [(UUID, CGRect)] = [] + var focused: [UUID] = [] + + init() throws { + let db = try AppDatabase.makeInMemory() + self.db = db + let box = Box() + self.tools = MCPTools(database: db, window: .init( + open: { id in await box.record { $0.opened.append(id) } }, + close: { id in await box.record { $0.closed.append(id) } }, + setFrame: { id, r in await box.record { $0.framed.append((id, r)) } }, + focus: { id in await box.record { $0.focused.append(id) } } + )) + box.harness = self + } + + // Bridges the tools' @Sendable off-actor closures back onto this @MainActor record. + final class Box: @unchecked Sendable { + weak var harness: Harness? + func record(_ mutate: @escaping @MainActor (Harness) -> Void) async { + await MainActor.run { if let h = harness { mutate(h) } } + } + } + } + + private func call(_ tools: MCPTools, _ name: String, _ args: [String: Value]) async -> (Value, Bool) { + let result = await tools.call(name, args) + let text = result.content.first.flatMap { if case let .text(t, _, _) = $0 { return t } else { return nil } } ?? "" + let value = (try? JSONDecoder().decode(Value.self, from: Data(text.utf8))) ?? .string(text) + return (value, result.isError ?? false) + } + + @Test("create_note seeds an outline, get_note reads it back, and the panel is opened") + @MainActor func createAndRead() async throws { + let h = try Harness() + let (created, err) = await call(h.tools, "create_note", [ + "title": "Groceries", + "color": "blue", + "tasks": .array([ + .string("Produce"), + .object(["text": "Apples", "level": 1]), + .object(["text": "Milk", "level": 5]), // over-deep → clamped to a valid outline + ]), + ]) + #expect(!err) + let id = try #require(created.objectValue?["note_id"]?.stringValue ?? created.objectValue?["id"]?.stringValue) + #expect(h.opened.map(\.uuidString) == [id]) + + let (note, err2) = await call(h.tools, "get_note", ["note_id": .string(id)]) + #expect(!err2) + let tasks = try #require(note.objectValue?["tasks"]?.arrayValue) + #expect(tasks.map { $0.objectValue?["text"]?.stringValue } == ["Produce", "Apples", "Milk"]) + #expect(tasks.map { $0.objectValue?["level"]?.intValue } == [0, 1, 2]) // 5 clamped to 2, still ≤ prev+1 + #expect(note.objectValue?["color"]?.stringValue == "blue") + } + + @Test("update_task done cascades to the whole subtree") + @MainActor func doneCascade() async throws { + let h = try Harness() + let (created, _) = await call(h.tools, "create_note", ["title": "P", + "tasks": .array([.string("Parent"), .object(["text": "A", "level": 1]), .object(["text": "B", "level": 1])])]) + let noteId = try #require(created.objectValue?["id"]?.stringValue) + let (note, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + let tasks = try #require(note.objectValue?["tasks"]?.arrayValue) + let parentId = try #require(tasks[0].objectValue?["id"]?.stringValue) + + let (_, err) = await call(h.tools, "update_task", ["task_id": .string(parentId), "done": true]) + #expect(!err) + let (after, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + let doneStates = try #require(after.objectValue?["tasks"]?.arrayValue).map { $0.objectValue?["done"]?.boolValue } + #expect(doneStates == [true, true, true]) // parent tick cascaded to both children + } + + @Test("move_task re-nests a row and preserves ticks") + @MainActor func moveReNest() async throws { + let h = try Harness() + let (created, _) = await call(h.tools, "create_note", ["title": "M", + "tasks": .array([.string("One"), .string("Two")])]) + let noteId = try #require(created.objectValue?["id"]?.stringValue) + let (note, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + let tasks = try #require(note.objectValue?["tasks"]?.arrayValue) + let secondId = try #require(tasks[1].objectValue?["id"]?.stringValue) + _ = await call(h.tools, "update_task", ["task_id": .string(secondId), "done": true]) + + let (_, err) = await call(h.tools, "move_task", ["task_id": .string(secondId), "level": 1]) + #expect(!err) + let (after, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + let rows = try #require(after.objectValue?["tasks"]?.arrayValue) + #expect(rows[1].objectValue?["level"]?.intValue == 1) // now a subtask of "One" + #expect(rows[1].objectValue?["done"]?.boolValue == true) // structural move kept the tick + } + + @Test("update_note changes colour and title and asks to bring the note forward") + @MainActor func updateNoteRoundTrip() async throws { + let h = try Harness() + let (created, _) = await call(h.tools, "create_note", ["title": "Old"]) + let id = try #require(created.objectValue?["id"]?.stringValue) + h.opened.removeAll() + + let (updated, err) = await call(h.tools, "update_note", + ["note_id": .string(id), "title": "New", "color": "pink", "collapsed": true, "open": true]) + #expect(!err) + #expect(updated.objectValue?["title"]?.stringValue == "New") + #expect(updated.objectValue?["color"]?.stringValue == "pink") + #expect(updated.objectValue?["collapsed"]?.boolValue == true) + #expect(h.opened.map(\.uuidString) == [id]) + } + + @Test("move_note persists the frame via the window action") + @MainActor func moveNoteAction() async throws { + let h = try Harness() + let (created, _) = await call(h.tools, "create_note", ["title": "F"]) + let id = try #require(created.objectValue?["id"]?.stringValue) + + let (frameRes, err) = await call(h.tools, "move_note", + ["note_id": .string(id), "x": 400.0, "y": 300.0, "width": 320.0, "height": 400.0]) + #expect(!err) + let fx = frameRes.objectValue?["frame"]?.objectValue?["x"] + #expect((fx?.doubleValue ?? fx?.intValue.map(Double.init)) == 400) + #expect(h.framed.count == 1) + #expect(h.framed.first?.1 == CGRect(x: 400, y: 300, width: 320, height: 400)) + } + + @Test("clear_completed and delete_tasks remove rows and re-level survivors") + @MainActor func removals() async throws { + let h = try Harness() + let (created, _) = await call(h.tools, "create_note", ["title": "R", + "tasks": .array([.string("Keep"), .string("Drop"), .string("Done")])]) + let noteId = try #require(created.objectValue?["id"]?.stringValue) + var (note, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + var tasks = try #require(note.objectValue?["tasks"]?.arrayValue) + let dropId = try #require(tasks[1].objectValue?["id"]?.stringValue) + let doneId = try #require(tasks[2].objectValue?["id"]?.stringValue) + + _ = await call(h.tools, "update_task", ["task_id": .string(doneId), "done": true]) + let (cleared, e1) = await call(h.tools, "clear_completed", ["note_id": .string(noteId)]) + #expect(!e1 && cleared.objectValue?["cleared"]?.intValue == 1) + let (deleted, e2) = await call(h.tools, "delete_tasks", ["task_ids": .array([.string(dropId)])]) + #expect(!e2 && deleted.objectValue?["deleted"]?.intValue == 1) + + (note, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + tasks = try #require(note.objectValue?["tasks"]?.arrayValue) + #expect(tasks.map { $0.objectValue?["text"]?.stringValue } == ["Keep"]) + } + + @Test("bad ids and unknown tools are tool errors, not crashes") + @MainActor func errors() async throws { + let h = try Harness() + let (_, e1) = await call(h.tools, "get_note", ["note_id": "not-a-uuid"]) + #expect(e1) + let (_, e2) = await call(h.tools, "get_note", ["note_id": .string(UUID().uuidString)]) + #expect(e2) // well-formed id, no such note + let (_, e3) = await call(h.tools, "nonsense", [:]) + #expect(e3) + let (_, e4) = await call(h.tools, "create_note", ["color": "chartreuse"]) + #expect(e4) // unknown colour + } + + // A valid 1x1 transparent PNG, base64. + private static let pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC" + + @Test("set/crop/remove image round-trips, and get_note reports has_image") + @MainActor func imageTools() async throws { + let h = try Harness() + let (created, _) = await call(h.tools, "create_note", ["title": "Pics", "tasks": .array([.string("Shot")])]) + let noteId = try #require(created.objectValue?["id"]?.stringValue) + let (note, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + let taskId = try #require(note.objectValue?["tasks"]?.arrayValue?[0].objectValue?["id"]?.stringValue) + + let (_, e1) = await call(h.tools, "set_task_image", ["task_id": .string(taskId), "data": .string(Self.pngBase64)]) + #expect(!e1) + var (after, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + #expect(after.objectValue?["tasks"]?.arrayValue?[0].objectValue?["has_image"]?.boolValue == true) + + // Crop out of range is clamped, not rejected. + let (cropRes, e2) = await call(h.tools, "crop_task_image", + ["task_id": .string(taskId), "x": 0.5, "y": 0.5, "width": 0.9, "height": 0.9]) + #expect(!e2) + func num(_ v: Value?) -> Double? { v?.doubleValue ?? v?.intValue.map(Double.init) } + #expect(num(cropRes.objectValue?["crop"]?.objectValue?["width"]) == 0.9) // requested size kept + #expect(abs((num(cropRes.objectValue?["crop"]?.objectValue?["x"]) ?? 0) - 0.1) < 0.0001) // x shifted to stay in bounds + + let (rmRes, e3) = await call(h.tools, "remove_task_image", ["task_id": .string(taskId)]) + #expect(!e3) + #expect(rmRes.objectValue?["deleted_task"]?.boolValue == false) // task has text → kept + (after, _) = await call(h.tools, "get_note", ["note_id": .string(noteId)]) + #expect(after.objectValue?["tasks"]?.arrayValue?[0].objectValue?["has_image"]?.boolValue == false) + } + + @Test("focus_note and focus:true bring the note to the foreground; a plain write only surfaces it") + @MainActor func focusing() async throws { + let h = try Harness() + // create_note without focus → surfaced (open), not focused. + let (created, _) = await call(h.tools, "create_note", ["title": "Watch me"]) + let id = try #require(created.objectValue?["id"]?.stringValue) + #expect(h.opened.map(\.uuidString) == [id]) + #expect(h.focused.isEmpty) + + // focus_note → focus action. + let (res, err) = await call(h.tools, "focus_note", ["note_id": .string(id)]) + #expect(!err) + #expect(res.objectValue?["focused"]?.stringValue == id) + #expect(h.focused.map(\.uuidString) == [id]) + + // update_note focus:true → focus, not a plain open. + h.opened.removeAll(); h.focused.removeAll() + let (_, err2) = await call(h.tools, "update_note", ["note_id": .string(id), "focus": true]) + #expect(!err2) + #expect(h.focused.map(\.uuidString) == [id]) + #expect(h.opened.isEmpty) + } +} diff --git a/Tests/TicTests/NoteControllerTests.swift b/Tests/TicTests/NoteControllerTests.swift index 26a9611..8fe0197 100644 --- a/Tests/TicTests/NoteControllerTests.swift +++ b/Tests/TicTests/NoteControllerTests.swift @@ -204,4 +204,38 @@ struct NoteControllerCompletedOptionsTests { #expect(c.note.hideCompleted) #expect(c.note.moveCompletedToBottom) } + + @Test("an out-of-band note write reaches the controller and drives the live window once") + func noteRowObservationDrivesWindow() async throws { + let db = try AppDatabase.makeInMemory() + var note = Note(title: "Before", floatOnTop: false) + try await db.insert(note) + let c = NoteController(note: note, database: db) + + var behaviorCalls: [(Bool, Bool)] = [] + var collapseCalls: [Bool] = [] + c.onApplyBehavior = { f, s in behaviorCalls.append((f, s)) } + c.onSetCollapsed = { collapseCalls.append($0) } + c.start() + defer { c.stop() } + + // An agent-style write straight to the DB (title + float + collapsed), bypassing the controller. + note.title = "After"; note.floatOnTop = true; note.isCollapsed = true + try await db.update(note) + + try await waitFor { c.note.title == "After" } + #expect(c.note.floatOnTop == true) + #expect(behaviorCalls.contains { $0.0 == true }) // float change drove the window + #expect(collapseCalls.contains(true)) // collapse change drove the window + } + + /// Polls the controller's optimistic/observed state until `condition` holds (the observation is async). + private func waitFor(_ condition: @escaping () -> Bool, timeout: Duration = .seconds(2)) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + #expect(condition(), "condition not met before timeout") + } } diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 9fcfeb4..916e1b5 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -3,7 +3,7 @@ import Site from '../layouts/Site.astro'; import Note, { type Task } from './Note.astro'; import Palette from './Palette.astro'; import Icon from './Icon.astro'; -import { site, release, features, shortcuts, faqs, welcomeTasks, weekTasks, groceryTasks } from '../data/site'; +import { site, release, features, shortcuts, faqs, welcomeTasks, weekTasks, groceryTasks, agentTasks, agentChat, mcpClients } from '../data/site'; import { competitors, comparePath } from '../data/compare'; const id = (name: string) => `${site.url}/#${name}`; @@ -114,6 +114,10 @@ const kbd = 'inline-grid min-w-8 place-items-center rounded-md bg-surface px-2 p Download for Mac View source on GitHub + + New — let Claude, Cursor & AI agents draft your lists + +

Free and open source{release && `, version ${release.version}`}. Needs macOS 14 Sonoma or later.

Tic - To-do lists that live on your desktop, not in a menu bar | Product Hunt @@ -131,6 +135,74 @@ const kbd = 'inline-grid min-w-8 place-items-center rounded-md bg-surface px-2 p

A to-do list you can see without going looking for it

{features.map((f, i) => ( + f.id === 'agents' ? ( +
+

+ New — works with AI agents +

+

{f.title}

+

{f.body}

+ +
+
+
+ + Your AI agent + connected to Tic +
+
+ {agentChat.map((turn, i) => ( + turn.who === 'user' ? ( +

{turn.text}

+ ) : turn.who === 'agent' ? ( +

+ {turn.text} + +

+ ) : ( +

+ + + Tic + · + {turn.label} + + +

+ ) + ))} +
+
+
+ + Message your agent… +
+ +
+
+ +
+
+ +
+
+
+ +
+

Set it up once for the client you use

+
    + {mcpClients.map((c) => ( +
  • + {c.name} +
  • + ))} +
+

+ Open AI Agents (MCP)… from the menu bar, flip it on, and copy the setup for your client — or add Cursor and VS Code in one click. It’s off by default and runs entirely on your Mac. +

+
+
+ ) : (

{f.title}

@@ -210,6 +282,7 @@ Groceries
)}
+ ) ))}
@@ -319,4 +392,89 @@ Groceries }); } }); + + // AI-agents section: a looping demo of a person talking to their agent while a Tic note fills in. + // Reduced motion / no JS keep the finished conversation static (CSS shows everything). + const agents = document.getElementById('agents'); + const calm = matchMedia('(prefers-reduced-motion: reduce)').matches; + if (agents && !calm) { + agents.setAttribute('data-play', ''); // reveal the framing (badge/heading/cards) + const msgs = [...agents.querySelectorAll('.msg')]; + const rows = [...agents.querySelectorAll('#agent-note .task')]; + const typed = agents.querySelector('.chat-typed'); + const placeholder = agents.querySelector('.chat-ph'); + const cascade = rows.slice(0, 3); // Flights + its two legs + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + const setDone = (task: Element, done: boolean) => { + task.toggleAttribute('data-done', done); + task.querySelector('.cb')?.setAttribute('aria-pressed', String(done)); + }; + // Type text into the chatbox one character at a time, then hold it briefly. Stops if retired. + async function typeInto(text: string, my: number) { + if (!typed) return; + placeholder?.classList.add('hidden'); + for (let i = 0; i < text.length; i++) { if (my !== token) return; typed.textContent = text.slice(0, i + 1); await sleep(34 + Math.random() * 42); } + await sleep(450); + } + const clearBox = () => { if (typed) typed.textContent = ''; placeholder?.classList.remove('hidden'); }; + + // A cancellation token. Every run captures the current value; bumping it retires that run so a + // re-entry (scroll away and back) can never leave two timelines mutating the note at once — + // which is what made the reveals land out of order. + let token = 0; + const live = (my: number) => my === token; + const reset = () => { + msgs.forEach((m) => m.classList.remove('in', 'pending')); + rows.forEach((r) => { r.classList.remove('in'); setDone(r, false); }); + clearBox(); + }; + + async function cycle(my: number) { + reset(); + await sleep(700); if (!live(my)) return; + for (const msg of msgs) { + if (!live(my)) return; + if (msg.dataset.who === 'user') { + // The person types their message into the chatbox, then it sends up into the log. + await typeInto(msg.textContent || '', my); if (!live(my)) return; + clearBox(); + msg.classList.add('in'); + await sleep(500); + continue; + } + // The agent "types" in the bubble's own slot (dots → text, no layout shift). + msg.classList.add('in', 'pending'); + await sleep(700); if (!live(my)) return; + msg.classList.remove('pending'); + // A tool call fills in the note rows it names, in order. + if (msg.dataset.reveal) { + for (const idx of msg.dataset.reveal.split(',')) { if (!live(my)) return; rows[+idx]?.classList.add('in'); await sleep(260); } + } + // The "done" message ticks Flights and both legs. + if (msg.dataset.tick !== undefined) { + cascade.forEach((task, i) => setTimeout(() => { if (live(my)) setDone(task, true); }, i * 260)); + } + await sleep(msg.dataset.reveal ? 500 : 900); + } + await sleep(3200); // hold the finished note, then loop + } + + async function loop() { + const my = token; + while (live(my)) await cycle(my); + } + + // Only animate while the section is on screen; each visibility change retires the old run first. + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver((entries) => { + const visible = entries.some((e) => e.isIntersecting); + token++; // retire whatever was running + if (visible) loop(); // start one fresh run + else reset(); + }, { threshold: 0.25 }); + observer.observe(agents); + } else { + loop(); + } + } diff --git a/site/src/data/site.ts b/site/src/data/site.ts index 000dd45..6163df5 100644 --- a/site/src/data/site.ts +++ b/site/src/data/site.ts @@ -33,7 +33,7 @@ export const site = { 'Tic is a free, open-source Mac app that keeps your to-do lists on the desktop as floating sticky notes, with subtasks, Markdown and keyboard shortcuts.', summary: 'Tic is a free, open-source macOS app that keeps to-do lists on the desktop as floating, Stickies-style sticky notes instead of hiding them behind a menu bar. Each list is its own small window with subtasks, inline Markdown, pasted images, solid or glass styles and keyboard shortcuts. It needs macOS 14 Sonoma or later.', - keywords: ['to-do list', 'sticky notes', 'Stickies alternative', 'desktop checklist', 'task manager', 'macOS', 'open source'], + keywords: ['to-do list', 'sticky notes', 'Stickies alternative', 'desktop checklist', 'task manager', 'macOS', 'open source', 'MCP', 'AI agent'], repo: 'https://github.com/kasvith/tic', download: 'https://github.com/kasvith/tic/releases/latest', changelog: 'https://github.com/kasvith/tic/blob/main/CHANGELOG.md', @@ -75,6 +75,45 @@ export const weekTasks: Task[] = [ { t: 'Book flights to Rome' }, ]; +// The MCP section: an agent building a trip list live. Seeded done so no-JS/reduced-motion shows the +// finished cascade; the script un-ticks then re-ticks to animate it. +export const agentTasks: Task[] = [ + { t: 'Flights', done: true }, + { t: 'Book outbound', l: 1, done: true }, + { t: 'Book return', l: 1, done: true }, + { t: 'Lodging' }, + { t: 'Food & coffee spots' }, +]; + +// The looping demo conversation. `reveal` lists the note rows (by index) a tool call fills in; +// `tick` marks the message that checks the Flights subtree off. Rendered as chat bubbles. +export type ChatTurn = + | { who: 'user'; text: string } + | { who: 'agent'; text: string } + | { who: 'tool'; label: string; reveal?: number[]; tick?: boolean }; +export const agentChat: ChatTurn[] = [ + { who: 'user', text: 'Plan a 3-day trip to Kyoto 🗾' }, + { who: 'agent', text: 'On it — I’ll set this up in Tic 👇' }, + { who: 'tool', label: 'Created the note “Kyoto trip”' }, + { who: 'tool', label: 'Added Flights, Book outbound, Book return', reveal: [0, 1, 2] }, + { who: 'tool', label: 'Added Lodging and Food & coffee', reveal: [3, 4] }, + { who: 'user', text: 'Book the flights and check them off' }, + { who: 'tool', label: 'Checked off Flights', tick: true }, + { who: 'agent', text: 'Done ✓ Flights and both legs are ticked.' }, +]; + +// Popular MCP clients that can drive Tic. Icons are Phosphor (generic, not brand logos). +export const mcpClients: { name: string; icon: string }[] = [ + { name: 'Claude', icon: 'sparkle' }, + { name: 'Claude Code', icon: 'terminal-window' }, + { name: 'Cursor', icon: 'cursor' }, + { name: 'VS Code', icon: 'code' }, + { name: 'Zed', icon: 'lightning' }, + { name: 'Codex CLI', icon: 'terminal-window' }, + { name: 'Gemini CLI', icon: 'sparkle' }, + { name: 'Windsurf', icon: 'wind' }, +]; + export const features = [ { id: 'float', @@ -86,6 +125,11 @@ export const features = [ title: 'Subtasks, notes and Markdown', body: 'Nest tasks three levels deep. Finish every subtask and the parent ticks itself off. Add a second line with Shift-Return, and write bold, italic, code, strikethrough or links right in the task.', }, + { + id: 'agents', + title: 'Let your AI agent draft the list', + body: 'Tic speaks MCP, so Claude, Cursor, Zed and the Codex or Gemini CLIs can create notes, draft tasks and tick them off — live on your desktop. It’s off by default, with one-click setup for each client.', + }, { id: 'images', title: 'Paste screenshots right into a task', @@ -144,6 +188,10 @@ export const faqs = [ q: 'Where are my lists stored?', a: 'In a local SQLite database at ~/Library/Application Support/Tic/tic.sqlite. Nothing leaves your Mac.', }, + { + q: 'Can AI agents add tasks to Tic?', + a: 'Yes. Turn on the MCP server from the menu bar (AI Agents (MCP)…) and agents like Claude, Cursor, VS Code, Zed and the Codex or Gemini CLIs can create notes, draft tasks, tick them off and more. The window gives you a one-click or copy-paste setup for each. It is off by default and runs entirely on your Mac.', + }, { q: 'Does Tic sync between Macs?', a: 'Not yet. Tic keeps everything local on one Mac for now.', diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 9d10e8e..a939903 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -54,6 +54,8 @@ const ogImage = new URL('/og.png', Astro.site); {jsonLd && diff --git a/site/src/layouts/Site.astro b/site/src/layouts/Site.astro index 81ac25c..a4e15ac 100644 --- a/site/src/layouts/Site.astro +++ b/site/src/layouts/Site.astro @@ -23,6 +23,7 @@ const { title, description, jsonLd } = Astro.props;