Shows your Todoist tasks in a side panel

A Claude Code mod that shows your Todoist tasks in a side panel.
/todoistAt the prompt of a Claude Code terminal session:
/plugin install todoist-pane --marketplace dannytennet/todoist-pane
Answer y to add the marketplace, then press Enter to pick the user scope.
From a shell:
claude plugin install todoist-pane --marketplace dannytennet/todoist-pane
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with dannytennet/todoist-pane, then Add plugin.
Get it in Todoist under Settings > Integrations > Developer. It's a secret, so it doesn't show in /config. Set it from a shell:
claude plugin configure todoist-pane --values-stdin
Paste {"api_token": "YOUR_TOKEN"}, press Enter, then Ctrl+D (Windows: Ctrl+Z, Enter). Then run /reload-plugins in Claude Code. claude plugin configure todoist-pane shows which settings are still unset.
These appear in /config under todoist-pane:
| Setting | What it does | |
|---|---|---|
| Todoist filter | Which tasks to show, in Todoist filter syntax. Default `today \ | overdue`. Empty shows all open tasks. |
| Refresh every (minutes) | Default 5. |
The panel needs a Claude Code session that can draw panels (a terminal session, or a local session in the desktop app) and network access to api.todoist.com.
hooks/register.tsx 120 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Feed, Task } from '../types'
5
6const PANE = 'todoist'
7const TITLE = 'Todoist'
8const API = 'https://api.todoist.com/api/v1'
9
10const feed = atom({ plugin: 'todoist-pane', key: 'feed' } as const, { tasks: [] } as Feed)
11
12type ApiTask = { id: string; content: string; priority: number; due?: { date?: string } | null }
13type ApiPage = { results?: ApiTask[]; next_cursor?: string | null }
14
15// Todoist's priority 4 is what the app shows as "P1".
16const priorityColor = (p: number) =>
17 p === 4 ? 'red' : p === 3 ? 'yellow' : p === 2 ? 'blue' : undefined
18
19type Cfg = { token: string; filter: string; minutes: number }
20
21function auth(cfg: Cfg) {
22 return { Authorization: `Bearer ${cfg.token}` }
23}
24
25async function refresh($: EngineInterface, cfg: Cfg) {
26 if (!cfg.token) {
27 await update($, feed, f => ({ ...f, error: 'Set your Todoist API token: run "claude plugin configure todoist-pane --values-stdin" in a shell (see README).' }))
28 return
29 }
30 try {
31 const tasks: Task[] = []
32 let cursor: string | null | undefined
33 do {
34 const params = new URLSearchParams({ limit: '200' })
35 if (cfg.filter) params.set('query', cfg.filter)
36 if (cursor) params.set('cursor', cursor)
37 const path = cfg.filter ? '/tasks/filter' : '/tasks'
38 const res = await $.http.fetch(`${API}${path}?${params}`, { headers: auth(cfg) })
39 if (!res.ok) throw new Error(`Todoist answered ${res.status}`)
40 const page = JSON.parse(res.text) as ApiPage
41 for (const t of page.results ?? []) {
42 tasks.push({ id: t.id, content: t.content, priority: t.priority, due: t.due?.date, isDone: false })
43 }
44 cursor = page.next_cursor
45 } while (cursor && tasks.length < 1000)
46
47 tasks.sort((a, b) => (a.due ?? '9999').localeCompare(b.due ?? '9999') || b.priority - a.priority)
48 const now = await $.clock.now()
49 await update($, feed, () => ({ tasks, updatedAt: now }))
50 } catch (err) {
51 const message = err instanceof Error ? err.message : String(err)
52 await update($, feed, f => ({ ...f, error: `Could not reach Todoist: ${message}` }))
53 }
54}
55
56async function complete($: EngineInterface, cfg: Cfg, id: string) {
57 await update($, feed, f => ({ ...f, tasks: f.tasks.map(t => (t.id === id ? { ...t, isDone: true } : t)) }))
58 const res = await $.http.fetch(`${API}/tasks/${id}/close`, { method: 'POST', headers: auth(cfg) })
59 if (!res.ok) {
60 $.ui.toast(`Todoist: could not complete task (${res.status})`)
61 await update($, feed, f => ({ ...f, tasks: f.tasks.map(t => (t.id === id ? { ...t, isDone: false } : t)) }))
62 }
63}
64
65export const register: Register = (on, options) => {
66 const cfg: Cfg = {
67 token: String(options.api_token ?? '').trim(),
68 filter: String(options.filter ?? '').trim(),
69 minutes: Math.max(1, Number(options.refresh_minutes ?? 5)),
70 }
71 const { filter, minutes } = cfg
72
73 on('session.start', async ($, e, next) => {
74 await $.command.register({ name: 'todoist', description: 'Open your Todoist tasks in a side panel' })
75 void $.ui.open({ id: PANE, title: TITLE })
76 void refresh($, cfg)
77 $.clock.every(minutes * 60_000, () => void refresh($, cfg))
78
79 return next(e)
80 })
81
82 on('command.run', { command: 'todoist' }, async $ => {
83 const opened = await $.ui.open({ id: PANE, title: TITLE })
84 void refresh($, cfg)
85 if (!opened.isPlaced) return { text: `Todoist panel could not be shown: ${opened.reason}` }
86
87 return { text: 'Todoist panel opened.' }
88 })
89
90 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
91 const { Box, Text, Button } = $.ui.resolve(e)
92 const { tasks, error, updatedAt } = await read($, feed)
93 const room = Math.max(1, (e.viewport?.rows ?? 24) - 5)
94 const open = tasks.filter(t => !t.isDone)
95
96 return (
97 <Box flexDirection="column">
98 <Box flexDirection="row" gap={1}>
99 <Text bold>{filter || 'All tasks'}</Text>
100 <Text dimColor>({open.length})</Text>
101 <Button key="refresh" plain onPress={() => refresh($, cfg)}>
102 <Text dimColor>refresh</Text>
103 </Button>
104 </Box>
105 {error && <Text color="red">{error}</Text>}
106 {!error && updatedAt && open.length === 0 && <Text dimColor>Nothing to do. Nice.</Text>}
107 {!error && !updatedAt && <Text dimColor>Loading…</Text>}
108 {open.slice(0, room).map(task => (
109 <Button key={`t-${task.id}`} plain onPress={() => complete($, cfg, task.id)}>
110 <Text color={priorityColor(task.priority)}>○</Text> {task.content}
111 {task.due ? <Text dimColor> {task.due}</Text> : ''}
112 </Button>
113 ))}
114 {open.length > room && <Text dimColor>+{open.length - room} more</Text>}
115 <Text dimColor>Click a task to complete it.</Text>
116 </Box>
117 )
118 })
119}
120types/index.d.ts 20 lines1export type Task = {
2 id: string
3 content: string
4 priority: number
5 due?: string
6 isDone: boolean
7}
8
9export type Feed = {
10 tasks: Task[]
11 error?: string
12 updatedAt?: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'todoist-pane': { feed: Feed }
18 }
19}
20