From f55b1919afc883a257dcff18d6005773c5ad1612 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 00:22:51 +0530 Subject: [PATCH 01/11] feat(mcp): spike the in-process MCP server + stdio bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the official MCP Swift SDK and prove the transport end to end before building tools. - `main.swift`: `Tic --mcp` runs the stdio↔socket proxy, else the GUI (drops `@main` from TicApp so the flag is checked before AppKit starts). - `MCPProxy`: pipes the client's stdio to the app's Unix socket, launches Tic when it isn't running, exits with a hint when MCP is off. - `MCPService`: `NWListener` on `~/Library/Application Support/Tic/mcp.sock` (0600), one SDK `Server` session per connection, live connection count. - `AppModel`: `mcpEnabled` preference (off by default) + start/stop; factor the support dir out of `AppDatabase`. - Tools list is empty for now — real tools come next. Verified: `swift test` (122 pass incl. socket handshake), scripted stdio client round-trips initialize/ping/tools-list through the proxy, three concurrent sessions stay isolated, no Dock icon for the proxy, `claude mcp add tic -- $PWD/.build/debug/Tic --mcp` → `claude mcp list` shows ✔ Connected (✘ when off). Plan: PLAN.md "MCP: AI agents drive Tic". --- PLAN.md | 177 +++++++++++++++++++++++++ Package.resolved | 65 ++++++++- Package.swift | 11 +- Sources/Tic/AppModel.swift | 36 +++++ Sources/Tic/Database/AppDatabase.swift | 11 +- Sources/Tic/MCP/MCPProxy.swift | 75 +++++++++++ Sources/Tic/MCP/MCPService.swift | 117 ++++++++++++++++ Sources/Tic/TicApp.swift | 1 - Sources/Tic/main.swift | 9 ++ Tests/TicTests/MCPServiceTests.swift | 47 +++++++ 10 files changed, 541 insertions(+), 8 deletions(-) create mode 100644 Sources/Tic/MCP/MCPProxy.swift create mode 100644 Sources/Tic/MCP/MCPService.swift create mode 100644 Sources/Tic/main.swift create mode 100644 Tests/TicTests/MCPServiceTests.swift 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..a6980e7 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. @@ -31,7 +32,11 @@ 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 } + } } /// Run once after launch: open saved note panels, then start streaming the notes list. @@ -39,6 +44,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 +125,36 @@ final class AppModel { searchWindow?.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/Database/AppDatabase.swift b/Sources/Tic/Database/AppDatabase.swift index a1c00e8..3738232 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() 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..951e0cb --- /dev/null +++ b/Sources/Tic/MCP/MCPService.swift @@ -0,0 +1,117 @@ +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 + /// 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)? + + 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", + capabilities: .init(tools: .init()) + ) + let key = ObjectIdentifier(server) + sessions[key] = server + onConnectionCountChange?(sessions.count) + + Task { [weak self] in + await server.withMethodHandler(ListTools.self) { _ in .init(tools: []) } + 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/TicApp.swift b/Sources/Tic/TicApp.swift index 71bd8cf..d0e92f3 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 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/MCPServiceTests.swift b/Tests/TicTests/MCPServiceTests.swift new file mode 100644 index 0000000..781b3d4 --- /dev/null +++ b/Tests/TicTests/MCPServiceTests.swift @@ -0,0 +1,47 @@ +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.isEmpty) + + 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)) + } +} From 49edb63fefccf8ff956b297b119d53fade527ca0 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 01:22:58 +0530 Subject: [PATCH 02/11] feat(mcp): full note+task tools with live UI updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can now do everything a person can to notes and tasks, and open panels reflect every write instantly. Tools (MCPTools, over AppDatabase, reusing TaskOutline for all structural rules): - notes: list_notes, get_note, create_note, update_note (title/colour/material/ flags/list-options/open), move_note, delete_note - tasks: add_tasks (outline via per-task level), update_task (text + done with the toggle cascade), move_task (reorder/re-nest), delete_tasks, clear_completed - trust-boundary validation: unknown/blank ids and text rejected, levels 0-2, batch + text capped; deletes carry destructiveHint; initialize sends an instructions string describing the outline + cascade. Live updates: - AppDatabase.observeNote(id:) + note(id:)/task(id:); NoteController streams its own note row and drives the window only for fields that changed (own writes echo back as no-ops). NoteView syncs the title field when unfocused. - NoteWindowManager gains openNoteByID/closeNoteByID/setFrame; AppModel wires them to MCPTools.WindowActions. Window actions go direct (not observer reconcile) to avoid the isOpen-write race. - TaskOutline.completionChanges shared by the controller and the tools. Tests: MCPToolsTests drives the dispatcher over an in-memory DB (create->get, level clamp, done cascade, move re-nest, update_note, move_note, delete/clear, bad ids); NoteControllerTests covers the note-row observer firing the window closures. 130 pass. Verified live: an agent-style sequence over the socket against the packaged app created a nested note, cascaded a parent tick, recoloured, retitled, added tasks and moved the window — all persisted and shown on screen. --- CLAUDE.md | 13 +- Sources/Tic/AppModel.swift | 6 + Sources/Tic/Controllers/NoteController.swift | 35 +- Sources/Tic/Database/AppDatabase+Tasks.swift | 4 + Sources/Tic/Database/AppDatabase.swift | 16 + Sources/Tic/MCP/MCPService.swift | 23 +- Sources/Tic/MCP/MCPTools.swift | 472 +++++++++++++++++++ Sources/Tic/Models/TaskOutline.swift | 9 + Sources/Tic/Views/NoteView.swift | 4 + Sources/Tic/Windows/NoteWindowManager.swift | 31 ++ Tests/TicTests/MCPServiceTests.swift | 3 +- Tests/TicTests/MCPToolsTests.swift | 173 +++++++ Tests/TicTests/NoteControllerTests.swift | 34 ++ 13 files changed, 816 insertions(+), 7 deletions(-) create mode 100644 Sources/Tic/MCP/MCPTools.swift create mode 100644 Tests/TicTests/MCPToolsTests.swift 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/Sources/Tic/AppModel.swift b/Sources/Tic/AppModel.swift index a6980e7..0e4115d 100644 --- a/Sources/Tic/AppModel.swift +++ b/Sources/Tic/AppModel.swift @@ -37,6 +37,12 @@ final class AppModel { 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) } + ) } /// Run once after launch: open saved note panels, then start streaming the notes list. 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..830be2e 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 diff --git a/Sources/Tic/Database/AppDatabase.swift b/Sources/Tic/Database/AppDatabase.swift index 3738232..8357fab 100644 --- a/Sources/Tic/Database/AppDatabase.swift +++ b/Sources/Tic/Database/AppDatabase.swift @@ -241,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/MCPService.swift b/Sources/Tic/MCP/MCPService.swift index 951e0cb..5d00b4a 100644 --- a/Sources/Tic/MCP/MCPService.swift +++ b/Sources/Tic/MCP/MCPService.swift @@ -16,6 +16,8 @@ final class MCPService: @unchecked Sendable { 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? @@ -23,6 +25,20 @@ final class MCPService: @unchecked Sendable { /// 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. + """ + init(database: AppDatabase, socketPath: String = MCPService.socketPath) { self.database = database self.socketPath = socketPath @@ -90,14 +106,19 @@ final class MCPService: @unchecked Sendable { 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 .init(tools: []) } + 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 { diff --git a/Sources/Tic/MCP/MCPTools.swift b/Sources/Tic/MCP/MCPTools.swift new file mode 100644 index 0000000..17dfaff --- /dev/null +++ b/Sources/Tic/MCP/MCPTools.swift @@ -0,0 +1,472 @@ +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 } + } + + private struct ToolError: Error { let message: String; init(_ m: String) { message = m } } + + private static let maxTasksPerCall = 200 + private static let maxTextLength = 20_000 + + // 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) + 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: - 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"), + ])), + + Tool(name: "update_note", + description: "Change a note's title, colour, material, window flags, or completed-task display. Set open=true to bring it to the front, open=false to hide it. Only the 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 brings the note to front, false hides it"), + ], 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: "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 or not. Ticking a parent ticks its whole subtree; finishing the last child auto-ticks the parent (and reopening a child reopens its parents).", + 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), + ] + } + + 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"])]), + ]) + } + + // 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) + return noteValue(note, tasks: tasks) + } + + 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) + } + } + + 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 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)]) + } + + // 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]?) -> 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), + ]) + }) + } + 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/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..d75cb84 100644 --- a/Sources/Tic/Windows/NoteWindowManager.swift +++ b/Sources/Tic/Windows/NoteWindowManager.swift @@ -238,6 +238,37 @@ final class NoteWindowManager: NSObject, NSWindowDelegate { var openCount: Int { panels.count } + // MARK: - Actions for external writers (MCP) + + /// Brings a note's panel to the front, 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. Doesn't + /// activate the app (never yanks the user out of what they're doing). + func openNoteByID(_ id: UUID) async { + if let panel = panels[id] { panel.orderFront(nil); return } + guard let note = try? await appDatabase.note(id: id) else { return } + openNote(note, makeKey: false) + } + + /// 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/Tests/TicTests/MCPServiceTests.swift b/Tests/TicTests/MCPServiceTests.swift index 781b3d4..7b322eb 100644 --- a/Tests/TicTests/MCPServiceTests.swift +++ b/Tests/TicTests/MCPServiceTests.swift @@ -26,7 +26,8 @@ struct MCPServiceTests { #expect(service.connectionCount == 1) let listed = try await client.listTools() - #expect(listed.tools.isEmpty) + #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 diff --git a/Tests/TicTests/MCPToolsTests.swift b/Tests/TicTests/MCPToolsTests.swift new file mode 100644 index 0000000..8efbfaa --- /dev/null +++ b/Tests/TicTests/MCPToolsTests.swift @@ -0,0 +1,173 @@ +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)] = [] + + 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)) } } + )) + 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 + } +} 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") + } } From d588ecf5319879b762611c50758f330b941e176a Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 09:14:45 +0530 Subject: [PATCH 03/11] feat(mcp): setup window + menu item with per-client install snippets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "AI Agents (MCP)…" item in the menu bar opens a window that owns the master toggle and shows copy-paste install instructions for each popular client. - MCPSetupView: toggle + live status ("Running · N connected"), a client list, and the selected client's snippet with Copy and Open-config-file. Warns when Tic isn't in /Applications (the executable path in the snippet would move). - MCPClients: one static table (Claude Desktop/Code, Cursor, VS Code, Codex, Gemini, Windsurf, Zed, Other) — each spawns the same `Tic --mcp`, varying only where the snippet goes and its format (mcpServers JSON / VS Code / Zed / TOML). The live executable path is baked into every snippet. - AppModel.openMCPSetup()/dismissMCPSetup() host it in a reused floating panel, same treatment as the Lists palette; menu item added in TicApp. - MCP ships OFF by default; the toggle now owns it. Tests: MCPClientsTests checks every snippet embeds the executable and --mcp, and that file vs command install kinds are shaped right. 132 pass. --- Sources/Tic/AppModel.swift | 36 +++++++ Sources/Tic/MCP/MCPClients.swift | 95 +++++++++++++++++ Sources/Tic/TicApp.swift | 2 + Sources/Tic/Views/MCPSetupView.swift | 150 +++++++++++++++++++++++++++ Tests/TicTests/MCPClientsTests.swift | 27 +++++ 5 files changed, 310 insertions(+) create mode 100644 Sources/Tic/MCP/MCPClients.swift create mode 100644 Sources/Tic/Views/MCPSetupView.swift create mode 100644 Tests/TicTests/MCPClientsTests.swift diff --git a/Sources/Tic/AppModel.swift b/Sources/Tic/AppModel.swift index 0e4115d..1b407e9 100644 --- a/Sources/Tic/AppModel.swift +++ b/Sources/Tic/AppModel.swift @@ -23,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 @@ -131,6 +132,41 @@ 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" diff --git a/Sources/Tic/MCP/MCPClients.swift b/Sources/Tic/MCP/MCPClients.swift new file mode 100644 index 0000000..13bc1ef --- /dev/null +++ b/Sources/Tic/MCP/MCPClients.swift @@ -0,0 +1,95 @@ +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 + + var name: String { id } + var configPath: String? { if case let .configFile(path) = install { return path } else { return nil } } +} + +enum MCPClients { + /// 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"] + } + } + } + """ + + 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: "Add this to Cursor's MCP config, then reload.", + install: .configFile(path: "~/.cursor/mcp.json"), + snippet: mcpServers), + + MCPClient(id: "VS Code", + detail: "Run this once (needs the GitHub Copilot MCP support).", + install: .command, + snippet: "code --add-mcp '{\"name\":\"tic\",\"command\":\"\(exe)\",\"args\":[\"--mcp\"]}'"), + + 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/TicApp.swift b/Sources/Tic/TicApp.swift index d0e92f3..58cdf09 100644 --- a/Sources/Tic/TicApp.swift +++ b/Sources/Tic/TicApp.swift @@ -115,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..f09dd1d --- /dev/null +++ b/Sources/Tic/Views/MCPSetupView.swift @@ -0,0 +1,150 @@ +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) { + 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 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/Tests/TicTests/MCPClientsTests.swift b/Tests/TicTests/MCPClientsTests.swift new file mode 100644 index 0000000..94235dd --- /dev/null +++ b/Tests/TicTests/MCPClientsTests.swift @@ -0,0 +1,27 @@ +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) + } +} From 40f46638d6fb04f3d6815976b2c181c7e5e4c024 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 09:23:59 +0530 Subject: [PATCH 04/11] feat(mcp): image tools + one-click Cursor/VS Code install deeplinks Round out agent parity with images, and make install one click where clients support it. Image tools (over TaskImage + the image DB methods, mirroring the UI): - set_task_image: base64 PNG/JPEG (or a data: URL), normalised via TaskImage.normalizedData, capped at 10 MB; replacing resets the crop. - crop_task_image: a 0-1 top-left rect, clamped in-bounds to a valid crop. - remove_task_image: deletes the image; an image-only (text-less) task is removed too, like the UI. - get_note now reports has_image per task (AppDatabase.taskImageIds/hasImage). Deeplinks: - MCPClients gains a deeplink for Cursor (base64 config) and VS Code (URL-encoded server object); MCPSetupView shows a prominent "Add to " button next to Copy for those clients. Tests: image set/crop/remove round-trip incl. has_image and clamp; deeplink shape. 134 pass. Verified live over the socket against the packaged app, and both deeplink URLs decode to the right config. --- Sources/Tic/Database/AppDatabase+Tasks.swift | 17 ++++ Sources/Tic/MCP/MCPClients.swift | 27 ++++++- Sources/Tic/MCP/MCPTools.swift | 81 +++++++++++++++++++- Sources/Tic/Views/MCPSetupView.swift | 9 +++ Tests/TicTests/MCPClientsTests.swift | 11 +++ Tests/TicTests/MCPToolsTests.swift | 33 ++++++++ 6 files changed, 172 insertions(+), 6 deletions(-) diff --git a/Sources/Tic/Database/AppDatabase+Tasks.swift b/Sources/Tic/Database/AppDatabase+Tasks.swift index 830be2e..5d05277 100644 --- a/Sources/Tic/Database/AppDatabase+Tasks.swift +++ b/Sources/Tic/Database/AppDatabase+Tasks.swift @@ -150,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/MCP/MCPClients.swift b/Sources/Tic/MCP/MCPClients.swift index 13bc1ef..d568460 100644 --- a/Sources/Tic/MCP/MCPClients.swift +++ b/Sources/Tic/MCP/MCPClients.swift @@ -14,12 +14,22 @@ struct MCPClient: Identifiable, Sendable { 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 = """ @@ -33,6 +43,14 @@ enum MCPClients { } """ + // 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.", @@ -45,14 +63,15 @@ enum MCPClients { snippet: "claude mcp add tic -- \"\(exe)\" --mcp"), MCPClient(id: "Cursor", - detail: "Add this to Cursor's MCP config, then reload.", + detail: "Click Add to Cursor, or paste this into Cursor's MCP config.", install: .configFile(path: "~/.cursor/mcp.json"), - snippet: mcpServers), + snippet: mcpServers, deeplink: cursorLink), MCPClient(id: "VS Code", - detail: "Run this once (needs the GitHub Copilot MCP support).", + 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\"]}'"), + snippet: "code --add-mcp '{\"name\":\"tic\",\"command\":\"\(exe)\",\"args\":[\"--mcp\"]}'", + deeplink: vscodeLink), MCPClient(id: "Codex CLI", detail: "Add this block to your Codex config.", diff --git a/Sources/Tic/MCP/MCPTools.swift b/Sources/Tic/MCP/MCPTools.swift index 17dfaff..5b0d06d 100644 --- a/Sources/Tic/MCP/MCPTools.swift +++ b/Sources/Tic/MCP/MCPTools.swift @@ -22,6 +22,7 @@ struct MCPTools: Sendable { private static let maxTasksPerCall = 200 private static let maxTextLength = 20_000 + private static let maxImageBytes = 10 * 1024 * 1024 // MARK: - Dispatch @@ -41,6 +42,9 @@ struct MCPTools: Sendable { 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) default: throw ToolError("Unknown tool: \(name)") } return CallTool.Result(content: [.text(json(value))], isError: false) @@ -139,6 +143,26 @@ struct MCPTools: Sendable { 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), ] } @@ -183,7 +207,8 @@ struct MCPTools: Sendable { let id = try args.uuid("note_id") let note = try await note(id) let tasks = try await database.tasks(noteId: id) - return noteValue(note, tasks: tasks) + let imageIds = try await database.taskImageIds(noteId: id) + return noteValue(note, tasks: tasks, imageIds: imageIds) } private func createNote(_ args: Args) async throws -> Value { @@ -354,6 +379,57 @@ struct MCPTools: Sendable { return .object(["cleared": .int(doneIds.count)]) } + // 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 { @@ -396,7 +472,7 @@ struct MCPTools: Sendable { return m } - private func noteValue(_ n: Note, tasks: [TaskItem]?) -> Value { + private func noteValue(_ n: Note, tasks: [TaskItem]?, imageIds: Set = []) -> Value { var o: [String: Value] = [ "id": .string(n.id.uuidString), "title": .string(n.title), @@ -417,6 +493,7 @@ struct MCPTools: Sendable { "text": .string(t.text), "level": .int(t.indentLevel), "done": .bool(t.isDone), + "has_image": .bool(imageIds.contains(t.id)), ]) }) } diff --git a/Sources/Tic/Views/MCPSetupView.swift b/Sources/Tic/Views/MCPSetupView.swift index f09dd1d..fbf852f 100644 --- a/Sources/Tic/Views/MCPSetupView.swift +++ b/Sources/Tic/Views/MCPSetupView.swift @@ -110,6 +110,10 @@ struct MCPSetupView: View { .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") } @@ -137,6 +141,11 @@ struct MCPSetupView: View { 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 diff --git a/Tests/TicTests/MCPClientsTests.swift b/Tests/TicTests/MCPClientsTests.swift index 94235dd..7a86172 100644 --- a/Tests/TicTests/MCPClientsTests.swift +++ b/Tests/TicTests/MCPClientsTests.swift @@ -24,4 +24,15 @@ struct MCPClientsTests { #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/MCPToolsTests.swift b/Tests/TicTests/MCPToolsTests.swift index 8efbfaa..61ef924 100644 --- a/Tests/TicTests/MCPToolsTests.swift +++ b/Tests/TicTests/MCPToolsTests.swift @@ -170,4 +170,37 @@ struct MCPToolsTests { 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) + } + + } From 45513904a03ab6a9f955f5be19c0f8dda3c42a53 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 09:53:05 +0530 Subject: [PATCH 05/11] feat(site): add an "AI agents" section for MCP, with a live-fill animation A new #agents section on the landing page introduces the MCP feature: an agent transcript (create_note, add_tasks, update_task) alongside a Tic note that fills in and ticks itself off as it scrolls into view. - site.ts: agentTasks, agentCalls, mcpClients data; an MCP FAQ (flows into llms.txt); MCP/AI-agent keywords. - Landing.astro: the section (badge, heading, transcript card, filling Note, "works with" client strip) + an IntersectionObserver timeline that reveals the rows and re-ticks the Flights subtree to show the cascade. Reduced-motion and no-JS fall back to the finished state; a failsafe timer guarantees it's never left blank. - global.css: rise keyframe + play-gated stagger. - Base.astro: sets a `.js` class early so the pre-play hidden state never flashes without JS. - Site.astro: "AI agents" nav link. Static build passes; verified the section renders and is well-placed. --- site/src/components/Landing.astro | 88 ++++++++++++++++++++++++++++++- site/src/data/site.ts | 37 ++++++++++++- site/src/layouts/Base.astro | 2 + site/src/layouts/Site.astro | 1 + site/src/styles/global.css | 22 ++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 9fcfeb4..8da079d 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, agentCalls, mcpClients } from '../data/site'; import { competitors, comparePath } from '../data/compare'; const id = (name: string) => `${site.url}/#${name}`; @@ -214,6 +214,61 @@ Groceries +
+
+

+ New — works with AI agents +

+

Let your AI agent draft the list

+

+ Tic speaks MCP, + so Claude, Cursor and friends can turn a plan into a checklist right on your desktop. + Ask, and tasks appear, nest and tick themselves off — live, in the note. No copy-paste. +

+
+ +
+ +
+ +
    + {agentCalls.map((call, i) => ( +
  1. + + {call.tool} + {call.args} + {call.note && — {call.note}} +
  2. + ))} +
+
+ + +
+ +
+
+ + +
+

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 is off by default and runs entirely on your Mac. +

+
+
+
@@ -319,4 +374,35 @@ Groceries }); } }); + + // AI-agents section: play the fill-in once when it scrolls into view. The note's first three rows + // start un-ticked, then re-tick in sequence to show the completion cascade the agent triggers. + const agents = document.getElementById('agents'); + const calm = matchMedia('(prefers-reduced-motion: reduce)').matches; + if (agents && !calm) { + const cascade = [...agents.querySelectorAll('#agent-note .task')].slice(0, 3); + const setDone = (task: Element, done: boolean) => { + task.toggleAttribute('data-done', done); + task.querySelector('.cb')?.setAttribute('aria-pressed', String(done)); + }; + cascade.forEach((task) => setDone(task, false)); // reset before the reveal + let played = false; + const play = () => { + if (played) return; + played = true; + agents.setAttribute('data-play', ''); + // After the rows have risen in (~2s of staggered entrance), tick Flights and its subtree. + cascade.forEach((task, i) => setTimeout(() => setDone(task, true), 2200 + i * 300)); + }; + if ('IntersectionObserver' in window) { + const observer = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting)) { observer.disconnect(); play(); } + }, { threshold: 0.2, rootMargin: '0px 0px -8% 0px' }); + observer.observe(agents); + // Failsafe: if the observer somehow never fires, reveal anyway so the section is never left blank. + setTimeout(play, 4000); + } else { + play(); // no IntersectionObserver: just show it + } + } diff --git a/site/src/data/site.ts b/site/src/data/site.ts index 000dd45..f1e1fef 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,37 @@ 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: 'Ryokan, 2 nights', l: 1 }, + { t: 'Food & coffee spots' }, +]; + +// The agent's tool calls, shown as a transcript beside the filling note. +export const agentCalls: { tool: string; args: string; note?: string }[] = [ + { tool: 'create_note', args: '“Kyoto trip” · green' }, + { tool: 'add_tasks', args: 'Flights · Lodging · Food' }, + { tool: 'add_tasks', args: 'Book outbound · Book return', note: 'under Flights' }, + { tool: 'update_task', args: 'Flights → done', note: 'subtree ticks too' }, +]; + +// 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', @@ -144,6 +175,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;
-
- -
- + +
+
+ + Message your agent… +
+ +
-
+
@@ -375,34 +390,83 @@ Groceries } }); - // AI-agents section: play the fill-in once when it scrolls into view. The note's first three rows - // start un-ticked, then re-tick in sequence to show the completion cascade the agent triggers. + // 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) { - const cascade = [...agents.querySelectorAll('#agent-note .task')].slice(0, 3); + agents.setAttribute('data-play', ''); // reveal the framing (badge/heading/cards) + const msgs = [...agents.querySelectorAll('.msg')]; + const rows = [...agents.querySelectorAll('#agent-note .task')]; + const dots = agents.querySelector('.dots'); + 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)); }; - cascade.forEach((task) => setDone(task, false)); // reset before the reveal - let played = false; - const play = () => { - if (played) return; - played = true; - agents.setAttribute('data-play', ''); - // After the rows have risen in (~2s of staggered entrance), tick Flights and its subtree. - cascade.forEach((task, i) => setTimeout(() => setDone(task, true), 2200 + i * 300)); + // Type text into the chatbox one character at a time, then hold it briefly. + async function typeInto(text: string) { + if (!typed) return; + placeholder?.classList.add('hidden'); + for (let i = 0; i < text.length; i++) { 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'); }; + + let running = false; + const reset = () => { + msgs.forEach((m) => m.classList.remove('in')); + rows.forEach((r) => { r.classList.remove('in'); setDone(r, false); }); + if (dots) dots.style.display = 'none'; + clearBox(); }; + + async function cycle() { + reset(); + await sleep(700); + for (const msg of msgs) { + if (msg.dataset.who === 'user') { + // The person types their message into the chatbox, then it sends up into the log. + await typeInto(msg.textContent || ''); + clearBox(); + msg.classList.add('in'); + await sleep(500); + continue; + } + // The agent "types" before each reply / tool call. + if (dots) { dots.style.display = 'flex'; await sleep(650); dots.style.display = 'none'; } + msg.classList.add('in'); + // A tool call fills in the note rows it names. + if (msg.dataset.reveal) { + for (const idx of msg.dataset.reveal.split(',')) { 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(() => setDone(task, true), i * 260)); + } + await sleep(msg.dataset.reveal ? 500 : 900); + } + await sleep(3200); // hold the finished note, then loop + } + + async function loop() { + running = true; + while (running) await cycle(); + } + + // Only animate while the section is on screen (saves work when scrolled away). if ('IntersectionObserver' in window) { const observer = new IntersectionObserver((entries) => { - if (entries.some((e) => e.isIntersecting)) { observer.disconnect(); play(); } - }, { threshold: 0.2, rootMargin: '0px 0px -8% 0px' }); + const visible = entries.some((e) => e.isIntersecting); + if (visible && !running) loop(); + else if (!visible) running = false; + }, { threshold: 0.25 }); observer.observe(agents); - // Failsafe: if the observer somehow never fires, reveal anyway so the section is never left blank. - setTimeout(play, 4000); } else { - play(); // no IntersectionObserver: just show it + loop(); } } diff --git a/site/src/data/site.ts b/site/src/data/site.ts index f1e1fef..74665b6 100644 --- a/site/src/data/site.ts +++ b/site/src/data/site.ts @@ -82,16 +82,24 @@ export const agentTasks: Task[] = [ { t: 'Book outbound', l: 1, done: true }, { t: 'Book return', l: 1, done: true }, { t: 'Lodging' }, - { t: 'Ryokan, 2 nights', l: 1 }, { t: 'Food & coffee spots' }, ]; -// The agent's tool calls, shown as a transcript beside the filling note. -export const agentCalls: { tool: string; args: string; note?: string }[] = [ - { tool: 'create_note', args: '“Kyoto trip” · green' }, - { tool: 'add_tasks', args: 'Flights · Lodging · Food' }, - { tool: 'add_tasks', args: 'Book outbound · Book return', note: 'under Flights' }, - { tool: 'update_task', args: 'Flights → done', note: 'subtree ticks too' }, +// 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; tick?: boolean } + | { who: 'tool'; tool: string; args: string; reveal?: number[] }; +export const agentChat: ChatTurn[] = [ + { who: 'user', text: 'Plan a 3-day trip to Kyoto 🗾' }, + { who: 'agent', text: 'On it — making you a note 👇' }, + { who: 'tool', tool: 'create_note', args: '“Kyoto trip” · green' }, + { who: 'tool', tool: 'add_tasks', args: 'Flights · Book outbound · Book return', reveal: [0, 1, 2] }, + { who: 'tool', tool: 'add_tasks', args: 'Lodging · Food & coffee', reveal: [3, 4] }, + { who: 'user', text: 'Book the flights and check them off' }, + { who: 'tool', tool: 'update_task', args: 'Flights → done' }, + { who: 'agent', text: 'Done ✓ Flights and both legs are checked off.', tick: true }, ]; // Popular MCP clients that can drive Tic. Icons are Phosphor (generic, not brand logos). diff --git a/site/src/styles/global.css b/site/src/styles/global.css index fb7ae9f..f12e2c2 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -118,24 +118,30 @@ p.headline-line .tick { width: 1.15em; height: 1.15em; margin: 0; color: var(--a @media (width < 40rem) { h1.headline-line .tick { width: 0.72em; height: 0.72em; margin-top: 0.1em; } } @media (hover: none) { .note-strip { opacity: 1; } } -/* ── AI agents section: entrance + live-fill animation ─────────────────────── */ -/* The `.js` class (set by an inline script) gates the pre-play hidden state, so - without JS everything is shown. Reduced motion opts out via the media query. */ +/* ── AI agents section: looping chat + live-fill demo ─────────────────────── */ +/* Elements transition both ways so a JS timeline can loop them. The `.js` class (set early) gates + the hidden start state, so without JS everything shows; reduced motion opts out via the media. */ @keyframes rise { from { opacity: 0; transform: translateY(0.6rem); } to { opacity: 1; transform: none; } } +@keyframes dot { 0%, 80%, 100% { transform: translateY(0); opacity: 0.45; } 40% { transform: translateY(-0.22rem); opacity: 1; } } + +[data-agent] .msg { transition: opacity 0.45s ease, transform 0.45s ease; } +[data-agent] #agent-note .task { transition: opacity 0.4s ease, transform 0.4s ease; } +[data-agent] .dots span { animation: dot 1.2s infinite; } +[data-agent] .dots span:nth-child(2) { animation-delay: 0.2s; } +[data-agent] .dots span:nth-child(3) { animation-delay: 0.4s; } @media (prefers-reduced-motion: no-preference) { - .js [data-agent]:not([data-play]) .anim, - .js [data-agent]:not([data-play]) .call, - .js [data-agent]:not([data-play]) #agent-note .task { opacity: 0; } - - [data-agent][data-play] .anim { animation: rise 0.55s both; animation-delay: calc(var(--i, 0) * 0.14s); } - [data-agent][data-play] .call { animation: rise 0.5s both; animation-delay: calc(var(--i, 0) * 0.16s); } - /* The note's rows rise in after the first couple of tool calls, one by one. */ - [data-agent][data-play] #agent-note .task { animation: rise 0.45s both; } - [data-agent][data-play] #agent-note .task:nth-of-type(1) { animation-delay: 0.7s; } - [data-agent][data-play] #agent-note .task:nth-of-type(2) { animation-delay: 0.9s; } - [data-agent][data-play] #agent-note .task:nth-of-type(3) { animation-delay: 1.1s; } - [data-agent][data-play] #agent-note .task:nth-of-type(4) { animation-delay: 1.3s; } - [data-agent][data-play] #agent-note .task:nth-of-type(5) { animation-delay: 1.5s; } - [data-agent][data-play] #agent-note .task:nth-of-type(6) { animation-delay: 1.7s; } + /* Section framing (badge, heading, cards) rises in once when scrolled into view. */ + .js [data-agent]:not([data-play]) .anim { opacity: 0; } + [data-agent][data-play] .anim { animation: rise 0.55s both; animation-delay: calc(var(--i, 0) * 0.12s); } + + /* Chat bubbles and note rows start hidden; the timeline reveals and resets them each loop. */ + .js [data-agent] .msg { opacity: 0; transform: translateY(0.6rem); } + .js [data-agent] .msg.in { opacity: 1; transform: none; } + .js [data-agent] #agent-note .task { opacity: 0; transform: translateY(0.4rem); } + .js [data-agent] #agent-note .task.in { opacity: 1; transform: none; } } + +/* Chatbox caret blink (the typing cursor in the demo's message input). */ +@keyframes chatcaret { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +[data-agent] .chat-caret { animation: chatcaret 1s steps(1) infinite; } From bcc2396f9c197a938d1ded9936634a0448526c03 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 10:25:21 +0530 Subject: [PATCH 08/11] fix(site): typing indicator no longer shifts the chat layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dots were a separate element toggled via display:none↔flex, which added and removed a row and pushed the chatbox around. Now each agent/tool bubble carries its own typing indicator as an absolutely-positioned overlay; a `.pending` class cross-fades dots → text by opacity only. The bubble's size is fixed by its text (reserved in layout), so the swap never reflows anything. Static build passes. --- site/src/components/Landing.astro | 26 ++++++++++++++------------ site/src/styles/global.css | 13 ++++++++++--- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 99dd20c..6e6eeef 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -240,17 +240,20 @@ Groceries turn.who === 'user' ? (

{turn.text}

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

{turn.text}

+

+ {turn.text} + +

) : ( -

- {turn.tool} - {turn.args} +

+ + {turn.tool} + {turn.args} + +

) ))} -
@@ -398,7 +401,6 @@ Groceries agents.setAttribute('data-play', ''); // reveal the framing (badge/heading/cards) const msgs = [...agents.querySelectorAll('.msg')]; const rows = [...agents.querySelectorAll('#agent-note .task')]; - const dots = agents.querySelector('.dots'); const typed = agents.querySelector('.chat-typed'); const placeholder = agents.querySelector('.chat-ph'); const cascade = rows.slice(0, 3); // Flights + its two legs @@ -420,7 +422,6 @@ Groceries const reset = () => { msgs.forEach((m) => m.classList.remove('in')); rows.forEach((r) => { r.classList.remove('in'); setDone(r, false); }); - if (dots) dots.style.display = 'none'; clearBox(); }; @@ -436,9 +437,10 @@ Groceries await sleep(500); continue; } - // The agent "types" before each reply / tool call. - if (dots) { dots.style.display = 'flex'; await sleep(650); dots.style.display = 'none'; } - msg.classList.add('in'); + // The agent "types" in the bubble's own slot (dots → text, no layout shift). + msg.classList.add('in', 'pending'); + await sleep(700); + msg.classList.remove('pending'); // A tool call fills in the note rows it names. if (msg.dataset.reveal) { for (const idx of msg.dataset.reveal.split(',')) { rows[+idx]?.classList.add('in'); await sleep(260); } diff --git a/site/src/styles/global.css b/site/src/styles/global.css index f12e2c2..29c15b9 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -126,9 +126,16 @@ p.headline-line .tick { width: 1.15em; height: 1.15em; margin: 0; color: var(--a [data-agent] .msg { transition: opacity 0.45s ease, transform 0.45s ease; } [data-agent] #agent-note .task { transition: opacity 0.4s ease, transform 0.4s ease; } -[data-agent] .dots span { animation: dot 1.2s infinite; } -[data-agent] .dots span:nth-child(2) { animation-delay: 0.2s; } -[data-agent] .dots span:nth-child(3) { animation-delay: 0.4s; } + +/* The typing indicator lives inside the bubble and overlays its text, so swapping dots → text + never changes the bubble's size or the layout around it (the reserved slot is the text). */ +[data-agent] .msg-body { transition: opacity 0.25s ease; } +[data-agent] .msg-typing { position: absolute; inset: 0; display: flex; align-items: center; gap: 0.35rem; padding-left: 0.9rem; opacity: 0; pointer-events: none; transition: opacity 0.2s ease; } +[data-agent] .msg-typing i { width: 0.5rem; height: 0.5rem; border-radius: 9999px; background: rgba(255, 255, 255, 0.7); animation: dot 1.2s infinite; } +[data-agent] .msg-typing i:nth-child(2) { animation-delay: 0.15s; } +[data-agent] .msg-typing i:nth-child(3) { animation-delay: 0.3s; } +[data-agent] .msg.pending .msg-body { opacity: 0; } +[data-agent] .msg.pending .msg-typing { opacity: 1; } @media (prefers-reduced-motion: no-preference) { /* Section framing (badge, heading, cards) rises in once when scrolled into view. */ From c592600852d8d35fef886daa4e4fbace98b673fb Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 10:37:09 +0530 Subject: [PATCH 09/11] feat(site): MCP as a feature in the list, with a real agent-UI demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks placement and the demo per feedback: - Features stay on top. MCP is now the third feature in the list (a full-width row with the animated demo), not a section above the features. - Mentioned in multiple places: a hero callout badge, the feature row, the nav. - The demo reads like a real agent UI: the panel is "Your AI agent" with a robot avatar and a "connected to Tic" status; tool calls show as branded actions ("Tic · Created the note …", "Tic · Checked off Flights") with the Tic app icon, instead of raw create_note/add_tasks names. - MCP added to the features data (so it's in the SoftwareApplication featureList too); nav order restored to Features first. Static build passes; demo verified in-browser. --- site/src/components/Landing.astro | 145 +++++++++++++++--------------- site/src/data/site.ts | 21 +++-- 2 files changed, 85 insertions(+), 81 deletions(-) diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 6e6eeef..957c956 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -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 @@ -127,10 +131,77 @@ 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,83 +281,11 @@ Groceries
)}
+ ) ))}
-
-
-

- New — works with AI agents -

-

Let your AI agent draft the list

-

- Tic speaks MCP, - so Claude, Cursor and friends can turn a plan into a checklist right on your desktop. - Ask, and tasks appear, nest and tick themselves off — live, in the note. No copy-paste. -

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

{turn.text}

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

- {turn.text} - -

- ) : ( -

- - {turn.tool} - {turn.args} - - -

- ) - ))} -
- -
-
- - 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 is off by default and runs entirely on your Mac. -

-
-
-
diff --git a/site/src/data/site.ts b/site/src/data/site.ts index 74665b6..6163df5 100644 --- a/site/src/data/site.ts +++ b/site/src/data/site.ts @@ -89,17 +89,17 @@ export const agentTasks: Task[] = [ // `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; tick?: boolean } - | { who: 'tool'; tool: string; args: string; reveal?: number[] }; + | { 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 — making you a note 👇' }, - { who: 'tool', tool: 'create_note', args: '“Kyoto trip” · green' }, - { who: 'tool', tool: 'add_tasks', args: 'Flights · Book outbound · Book return', reveal: [0, 1, 2] }, - { who: 'tool', tool: 'add_tasks', args: 'Lodging · Food & coffee', reveal: [3, 4] }, + { 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', tool: 'update_task', args: 'Flights → done' }, - { who: 'agent', text: 'Done ✓ Flights and both legs are checked off.', tick: true }, + { 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). @@ -125,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', From 3c91d06de7ccd4967bc68d47ed32d79217e0ba9c Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 10:44:43 +0530 Subject: [PATCH 10/11] fix(site): order-safe agent animation + seat the note on Tic's desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design pass on the AI-agents feature: - The note now sits on Tic's own wallpaper gradient (.desk-stage), so it reads as a note that appeared on your Mac desktop — tying the feature to Tic's core idea and giving the bright, on-brand counterpoint to the agent's dark chat. Palette and type stay Tic's, so nothing looks templated. - Fixed out-of-order reveals: the loop is now guarded by a cancellation token, so scrolling away and back retires the running timeline before starting a fresh one instead of leaving two timelines mutating the note at once. Static build passes. --- site/src/components/Landing.astro | 47 ++++++++++++++++++------------- site/src/styles/global.css | 4 +++ 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index 957c956..916e1b5 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -131,7 +131,6 @@ 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

@@ -182,8 +181,10 @@ const kbd = 'inline-grid min-w-8 place-items-center rounded-md bg-surface px-2 p
-
- +
+
+ +
@@ -408,29 +409,34 @@ Groceries 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. - async function typeInto(text: string) { + // 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++) { typed.textContent = text.slice(0, i + 1); await sleep(34 + Math.random() * 42); } + 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'); }; - let running = false; + // 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')); + msgs.forEach((m) => m.classList.remove('in', 'pending')); rows.forEach((r) => { r.classList.remove('in'); setDone(r, false); }); clearBox(); }; - async function cycle() { + async function cycle(my: number) { reset(); - await sleep(700); + 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 || ''); + await typeInto(msg.textContent || '', my); if (!live(my)) return; clearBox(); msg.classList.add('in'); await sleep(500); @@ -438,15 +444,15 @@ Groceries } // The agent "types" in the bubble's own slot (dots → text, no layout shift). msg.classList.add('in', 'pending'); - await sleep(700); + await sleep(700); if (!live(my)) return; msg.classList.remove('pending'); - // A tool call fills in the note rows it names. + // A tool call fills in the note rows it names, in order. if (msg.dataset.reveal) { - for (const idx of msg.dataset.reveal.split(',')) { rows[+idx]?.classList.add('in'); await sleep(260); } + 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(() => setDone(task, true), i * 260)); + cascade.forEach((task, i) => setTimeout(() => { if (live(my)) setDone(task, true); }, i * 260)); } await sleep(msg.dataset.reveal ? 500 : 900); } @@ -454,16 +460,17 @@ Groceries } async function loop() { - running = true; - while (running) await cycle(); + const my = token; + while (live(my)) await cycle(my); } - // Only animate while the section is on screen (saves work when scrolled away). + // 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); - if (visible && !running) loop(); - else if (!visible) running = false; + token++; // retire whatever was running + if (visible) loop(); // start one fresh run + else reset(); }, { threshold: 0.25 }); observer.observe(agents); } else { diff --git a/site/src/styles/global.css b/site/src/styles/global.css index 29c15b9..4dccc3a 100644 --- a/site/src/styles/global.css +++ b/site/src/styles/global.css @@ -152,3 +152,7 @@ p.headline-line .tick { width: 1.15em; height: 1.15em; margin: 0; color: var(--a /* Chatbox caret blink (the typing cursor in the demo's message input). */ @keyframes chatcaret { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } [data-agent] .chat-caret { animation: chatcaret 1s steps(1) infinite; } + +/* The AI-agents note sits on Tic's own desktop wallpaper, so it reads as a note that appeared on + your Mac — tying the feature to Tic's "lists that live on your desktop" idea. */ +.desk-stage { background: var(--wallpaper); } From 074443ef8aaac02f72c2843623554f48cebe8602 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 18 Sep 2026 10:56:03 +0530 Subject: [PATCH 11/11] style(mcp): clear SwiftLint errors - Split the tool schema into MCPTools+Definitions.swift so the handler struct's body is back under the type-body limit. - Tighten four tool descriptions that ran past the 200-char hard line limit. No behaviour change; 135 tests pass. --- Sources/Tic/MCP/MCPTools+Definitions.swift | 148 +++++++++++++++++++++ Sources/Tic/MCP/MCPTools.swift | 140 ------------------- 2 files changed, 148 insertions(+), 140 deletions(-) create mode 100644 Sources/Tic/MCP/MCPTools+Definitions.swift 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 index 23a1b6d..157e1d1 100644 --- a/Sources/Tic/MCP/MCPTools.swift +++ b/Sources/Tic/MCP/MCPTools.swift @@ -57,146 +57,6 @@ struct MCPTools: Sendable { } } - // 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. Set open=true to bring it to the front, open=false to hide it. Only the 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 foreground and focus it — activates Tic and makes the note the key window, so the user is looking right at it. Use when you want to draw attention to a note; plain writes surface a note above other apps 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 or not. Ticking a parent ticks its whole subtree; finishing the last child auto-ticks the parent (and reopening a child reopens its parents).", - 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"])]), - ]) - } - // MARK: - Note tools private func listNotes() async throws -> Value {