Created
June 16, 2026 07:07
-
-
Save Keboo/7db90100e73146fddbc2fd2de06027e5 to your computer and use it in GitHub Desktop.
Copilot extension: todo-list
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Extension: todo-list | |
| // Interactive TODO list canvas. Todos persist to .tmp/todos.json in the | |
| // workspace root and update live via SSE across all open canvas panels. | |
| import { createServer } from "node:http"; | |
| import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; | |
| import { join } from "node:path"; | |
| import { randomUUID } from "node:crypto"; | |
| import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; | |
| const servers = new Map(); // instanceId → { server, url } | |
| const sseClients = new Map(); // instanceId → Set<ServerResponse> | |
| // ── Persistence ───────────────────────────────────────────────────────────── | |
| function todosPath(workspacePath) { | |
| const dir = join(workspacePath || process.cwd(), ".tmp"); | |
| if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); | |
| return join(dir, "todos.json"); | |
| } | |
| function loadTodos(workspacePath) { | |
| const p = todosPath(workspacePath); | |
| if (!existsSync(p)) return []; | |
| try { return JSON.parse(readFileSync(p, "utf-8")); } catch { return []; } | |
| } | |
| function saveTodos(todos, workspacePath) { | |
| writeFileSync(todosPath(workspacePath), JSON.stringify(todos, null, 2), "utf-8"); | |
| } | |
| // ── SSE broadcast ──────────────────────────────────────────────────────────── | |
| function broadcast(todos) { | |
| const payload = `data: ${JSON.stringify(todos)}\n\n`; | |
| for (const clients of sseClients.values()) { | |
| for (const res of clients) res.write(payload); | |
| } | |
| } | |
| // ── HTTP helpers ───────────────────────────────────────────────────────────── | |
| function readBody(req) { | |
| return new Promise((resolve, reject) => { | |
| let body = ""; | |
| req.on("data", c => (body += c)); | |
| req.on("end", () => resolve(body)); | |
| req.on("error", reject); | |
| }); | |
| } | |
| // ── HTML renderer ───────────────────────────────────────────────────────────── | |
| function renderHtml() { | |
| // NOTE: inline onclick/onchange handlers with JS string escaping are | |
| // unreliable inside template literals. Use data-id + event delegation. | |
| const js = ` | |
| let todos = [], filter = 'all'; | |
| const es = new EventSource('/events'); | |
| es.onmessage = e => { todos = JSON.parse(e.data); render(); }; | |
| fetch('/api/todos').then(r => r.json()).then(d => { todos = d; render(); }); | |
| function esc(s) { | |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| function render() { | |
| const vis = todos.filter(t => | |
| filter === 'all' ? true : filter === 'done' ? t.done : !t.done | |
| ); | |
| const ul = document.getElementById('todo-list'); | |
| if (!vis.length) { | |
| ul.innerHTML = '<li class="empty">' + | |
| (filter === 'done' ? 'No completed tasks.' : 'No tasks yet — add one above.') + | |
| '</li>'; | |
| } else { | |
| ul.innerHTML = vis.map(t => [ | |
| '<li class="todo-item" data-id="', t.id, '">', | |
| '<input type="checkbox" data-id="', t.id, '"', t.done ? ' checked' : '', '>', | |
| '<span class="todo-text', t.done ? ' done' : '', '">', esc(t.text), '</span>', | |
| '<button class="btn-del" data-id="', t.id, '" title="Delete">x</button>', | |
| '</li>', | |
| ].join('')).join(''); | |
| } | |
| const active = todos.filter(t => !t.done).length; | |
| document.getElementById('count').textContent = | |
| active + ' item' + (active !== 1 ? 's' : '') + ' left'; | |
| } | |
| // Event delegation — no inline handlers | |
| document.getElementById('todo-list').addEventListener('change', e => { | |
| if (e.target.type !== 'checkbox') return; | |
| const id = e.target.dataset.id; | |
| const t = todos.find(x => x.id === id); | |
| if (!t) return; | |
| fetch('/api/todos/' + id, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ done: !t.done }), | |
| }); | |
| }); | |
| document.getElementById('todo-list').addEventListener('click', e => { | |
| const btn = e.target.closest('.btn-del'); | |
| if (!btn) return; | |
| fetch('/api/todos/' + btn.dataset.id, { method: 'DELETE' }); | |
| }); | |
| function setFilter(f) { | |
| filter = f; | |
| ['all','active','done'].forEach(x => | |
| document.getElementById('f-' + x).classList.toggle('active', x === f) | |
| ); | |
| render(); | |
| } | |
| async function addTodo() { | |
| const inp = document.getElementById('new-todo'); | |
| const text = inp.value.trim(); | |
| if (!text) return; | |
| inp.value = ''; | |
| await fetch('/api/todos', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ text }), | |
| }); | |
| } | |
| async function clearCompleted() { | |
| await fetch('/api/todos/clear-completed', { method: 'POST' }); | |
| } | |
| document.getElementById('new-todo').addEventListener('keydown', e => { | |
| if (e.key === 'Enter') addTodo(); | |
| }); | |
| document.getElementById('btn-add').addEventListener('click', addTodo); | |
| document.getElementById('btn-clear').addEventListener('click', clearCompleted); | |
| ['all','active','done'].forEach(f => | |
| document.getElementById('f-' + f).addEventListener('click', () => setFilter(f)) | |
| ); | |
| `; | |
| return `<!doctype html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>TODO List</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); | |
| font-size: var(--text-body-medium, 14px); | |
| line-height: var(--leading-body-medium, 20px); | |
| background: var(--background-color-default, #ffffff); | |
| color: var(--text-color-default, #1f2328); | |
| padding: 16px; | |
| } | |
| h1 { | |
| font-size: var(--text-title-medium, 20px); | |
| font-weight: var(--font-weight-semibold, 600); | |
| color: var(--text-color-default); | |
| margin-bottom: 16px; | |
| } | |
| .add-row { display: flex; gap: 8px; margin-bottom: 12px; } | |
| input[type="text"] { | |
| flex: 1; padding: 6px 10px; | |
| border: 1px solid var(--border-color-default, #d0d7de); | |
| border-radius: 6px; | |
| background: var(--background-color-default); | |
| color: var(--text-color-default); | |
| font-size: inherit; font-family: inherit; outline: none; | |
| } | |
| input[type="text"]::placeholder { color: var(--text-color-muted); } | |
| input[type="text"]:focus { | |
| border-color: var(--color-focus-outline, #0969da); | |
| box-shadow: 0 0 0 2px rgba(9,105,218,0.2); | |
| } | |
| button { | |
| padding: 6px 14px; | |
| border: 1px solid transparent; border-radius: 6px; cursor: pointer; | |
| font-size: inherit; font-family: inherit; | |
| font-weight: var(--font-weight-semibold, 600); | |
| } | |
| .btn-primary { background: var(--color-accent-fg, #0969da); color: #fff; } | |
| .btn-primary:hover { opacity: 0.88; } | |
| .filters { display: flex; gap: 4px; margin-bottom: 12px; } | |
| .filter-btn { | |
| background: transparent; | |
| border: none; | |
| border-bottom: 2px solid transparent; | |
| border-radius: 0; | |
| color: var(--text-color-muted); | |
| padding: 4px 10px 2px; font-weight: normal; | |
| } | |
| .filter-btn.active { | |
| color: var(--text-color-default); | |
| border-bottom-color: var(--color-accent-fg, #0969da); | |
| } | |
| .filter-btn:hover:not(.active) { color: var(--text-color-default); } | |
| .todo-list { | |
| list-style: none; | |
| border: 1px solid var(--border-color-default, #d0d7de); | |
| border-radius: 8px; overflow: hidden; | |
| } | |
| .todo-item { | |
| display: flex; align-items: center; gap: 10px; | |
| padding: 10px 14px; | |
| border-bottom: 1px solid var(--border-color-default, #d0d7de); | |
| /* Lock text color so hover background changes never affect contrast */ | |
| color: var(--text-color-default, #1f2328); | |
| } | |
| .todo-item:last-child { border-bottom: none; } | |
| .todo-item:hover { | |
| /* Use a border-left accent so we never rely on bg/text contrast ratio */ | |
| border-left: 3px solid var(--color-accent-fg, #0969da); | |
| padding-left: 11px; /* compensate for 3px border */ | |
| } | |
| .todo-item input[type="checkbox"] { | |
| width: 15px; height: 15px; flex-shrink: 0; cursor: pointer; | |
| accent-color: var(--color-accent-fg, #0969da); | |
| } | |
| .todo-text { flex: 1; color: inherit; } | |
| .todo-text.done { | |
| text-decoration: line-through; | |
| /* Muted but still readable — WCAG AA requires ≥4.5:1 against both themes */ | |
| color: var(--text-color-muted, #656d76); | |
| } | |
| .btn-del { | |
| background: transparent; border: none; | |
| color: var(--text-color-muted, #656d76); | |
| font-size: 18px; padding: 0 4px; cursor: pointer; line-height: 1; | |
| font-weight: normal; | |
| } | |
| .btn-del:hover { color: var(--true-color-red, #cf222e); } | |
| .empty { text-align: center; color: var(--text-color-muted, #656d76); padding: 28px 16px; } | |
| .footer { | |
| display: flex; justify-content: space-between; align-items: center; | |
| margin-top: 10px; | |
| color: var(--text-color-muted, #656d76); font-size: 12px; | |
| } | |
| .btn-clear { | |
| background: transparent; border: none; cursor: pointer; | |
| color: var(--text-color-muted, #656d76); | |
| font-size: 12px; font-family: inherit; padding: 0; | |
| } | |
| .btn-clear:hover { color: var(--true-color-red, #cf222e); } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>📋 TODO List</h1> | |
| <div class="add-row"> | |
| <input type="text" id="new-todo" placeholder="Add a new task…" autocomplete="off" /> | |
| <button id="btn-add" class="btn-primary">Add</button> | |
| </div> | |
| <div class="filters"> | |
| <button class="filter-btn active" id="f-all">All</button> | |
| <button class="filter-btn" id="f-active">Active</button> | |
| <button class="filter-btn" id="f-done">Done</button> | |
| </div> | |
| <ul class="todo-list" id="todo-list"></ul> | |
| <div class="footer"> | |
| <span id="count">0 items left</span> | |
| <button id="btn-clear" class="btn-clear">Clear completed</button> | |
| </div> | |
| <script>${js}</script> | |
| </body> | |
| </html>`; | |
| } | |
| // ── Per-instance HTTP server ────────────────────────────────────────────────── | |
| async function startServer(instanceId, getWorkspacePath) { | |
| const clients = new Set(); | |
| sseClients.set(instanceId, clients); | |
| const server = createServer(async (req, res) => { | |
| const url = new URL(req.url, "http://localhost"); | |
| const wp = getWorkspacePath(); | |
| // SSE | |
| if (url.pathname === "/events") { | |
| res.setHeader("Content-Type", "text/event-stream"); | |
| res.setHeader("Cache-Control", "no-cache"); | |
| res.setHeader("Connection", "keep-alive"); | |
| res.flushHeaders(); | |
| clients.add(res); | |
| res.write(`data: ${JSON.stringify(loadTodos(wp))}\n\n`); | |
| req.on("close", () => clients.delete(res)); | |
| return; | |
| } | |
| // GET /api/todos | |
| if (req.method === "GET" && url.pathname === "/api/todos") { | |
| res.setHeader("Content-Type", "application/json"); | |
| res.end(JSON.stringify(loadTodos(wp))); | |
| return; | |
| } | |
| // POST /api/todos | |
| if (req.method === "POST" && url.pathname === "/api/todos") { | |
| const { text } = JSON.parse(await readBody(req)); | |
| if (!text?.trim()) { res.statusCode = 400; res.end(); return; } | |
| const todos = loadTodos(wp); | |
| todos.push({ id: randomUUID(), text: text.trim(), done: false, createdAt: new Date().toISOString() }); | |
| saveTodos(todos, wp); | |
| broadcast(todos); | |
| res.setHeader("Content-Type", "application/json"); | |
| res.end(JSON.stringify(todos)); | |
| return; | |
| } | |
| // POST /api/todos/clear-completed (must precede :id routes) | |
| if (req.method === "POST" && url.pathname === "/api/todos/clear-completed") { | |
| const todos = loadTodos(wp).filter(t => !t.done); | |
| saveTodos(todos, wp); | |
| broadcast(todos); | |
| res.setHeader("Content-Type", "application/json"); | |
| res.end(JSON.stringify(todos)); | |
| return; | |
| } | |
| // PUT /api/todos/:id and DELETE /api/todos/:id | |
| const idMatch = url.pathname.match(/^\/api\/todos\/([^/]+)$/); | |
| if (idMatch) { | |
| const id = idMatch[1]; | |
| if (req.method === "PUT") { | |
| const updates = JSON.parse(await readBody(req)); | |
| const todos = loadTodos(wp); | |
| const todo = todos.find(t => t.id === id); | |
| if (!todo) { res.statusCode = 404; res.end(); return; } | |
| Object.assign(todo, updates); | |
| saveTodos(todos, wp); | |
| broadcast(todos); | |
| res.setHeader("Content-Type", "application/json"); | |
| res.end(JSON.stringify(todos)); | |
| return; | |
| } | |
| if (req.method === "DELETE") { | |
| const todos = loadTodos(wp).filter(t => t.id !== id); | |
| saveTodos(todos, wp); | |
| broadcast(todos); | |
| res.setHeader("Content-Type", "application/json"); | |
| res.end(JSON.stringify(todos)); | |
| return; | |
| } | |
| } | |
| // Default: serve app shell | |
| res.setHeader("Content-Type", "text/html; charset=utf-8"); | |
| res.end(renderHtml()); | |
| }); | |
| await new Promise(r => server.listen(0, "127.0.0.1", r)); | |
| const addr = server.address(); | |
| const port = addr && typeof addr === "object" ? addr.port : 0; | |
| return { server, url: `http://127.0.0.1:${port}/` }; | |
| } | |
| // ── Extension entry point ───────────────────────────────────────────────────── | |
| const session = await joinSession({ | |
| canvases: [ | |
| createCanvas({ | |
| id: "todo-list", | |
| displayName: "TODO List", | |
| description: "Interactive TODO list for tracking current work tasks — add, complete, and remove items.", | |
| actions: [ | |
| { | |
| name: "add_todo", | |
| description: "Add a new TODO item to the list", | |
| inputSchema: { | |
| type: "object", | |
| properties: { text: { type: "string", description: "Task description" } }, | |
| required: ["text"], | |
| }, | |
| handler: async (ctx) => { | |
| const todos = loadTodos(session.workspacePath); | |
| const todo = { | |
| id: randomUUID(), | |
| text: ctx.input.text.trim(), | |
| done: false, | |
| createdAt: new Date().toISOString(), | |
| }; | |
| todos.push(todo); | |
| saveTodos(todos, session.workspacePath); | |
| broadcast(todos); | |
| return todo; | |
| }, | |
| }, | |
| { | |
| name: "update_todo", | |
| description: "Update a TODO item's text or completion status", | |
| inputSchema: { | |
| type: "object", | |
| properties: { | |
| id: { type: "string", description: "Todo item ID" }, | |
| text: { type: "string", description: "New text (optional)" }, | |
| done: { type: "boolean", description: "New done status (optional)" }, | |
| }, | |
| required: ["id"], | |
| }, | |
| handler: async (ctx) => { | |
| const todos = loadTodos(session.workspacePath); | |
| const todo = todos.find(t => t.id === ctx.input.id); | |
| if (!todo) throw new CanvasError("not_found", `Todo not found: ${ctx.input.id}`); | |
| if (ctx.input.text !== undefined) todo.text = ctx.input.text; | |
| if (ctx.input.done !== undefined) todo.done = ctx.input.done; | |
| saveTodos(todos, session.workspacePath); | |
| broadcast(todos); | |
| return todo; | |
| }, | |
| }, | |
| { | |
| name: "delete_todo", | |
| description: "Delete a TODO item by ID", | |
| inputSchema: { | |
| type: "object", | |
| properties: { id: { type: "string", description: "Todo item ID" } }, | |
| required: ["id"], | |
| }, | |
| handler: async (ctx) => { | |
| const todos = loadTodos(session.workspacePath); | |
| const before = todos.length; | |
| const remaining = todos.filter(t => t.id !== ctx.input.id); | |
| if (remaining.length === before) | |
| throw new CanvasError("not_found", `Todo not found: ${ctx.input.id}`); | |
| saveTodos(remaining, session.workspacePath); | |
| broadcast(remaining); | |
| return { deleted: ctx.input.id }; | |
| }, | |
| }, | |
| { | |
| name: "list_todos", | |
| description: "List all TODO items", | |
| handler: async () => loadTodos(session.workspacePath), | |
| }, | |
| { | |
| name: "clear_completed", | |
| description: "Remove all completed TODO items", | |
| handler: async () => { | |
| const all = loadTodos(session.workspacePath); | |
| const remaining = all.filter(t => !t.done); | |
| saveTodos(remaining, session.workspacePath); | |
| broadcast(remaining); | |
| return { removed: all.length - remaining.length, remaining: remaining.length }; | |
| }, | |
| }, | |
| ], | |
| open: async (ctx) => { | |
| let entry = servers.get(ctx.instanceId); | |
| if (!entry) { | |
| entry = await startServer(ctx.instanceId, () => session.workspacePath); | |
| servers.set(ctx.instanceId, entry); | |
| } | |
| return { title: "TODO List", url: entry.url }; | |
| }, | |
| onClose: async (ctx) => { | |
| const entry = servers.get(ctx.instanceId); | |
| if (entry) { | |
| servers.delete(ctx.instanceId); | |
| sseClients.delete(ctx.instanceId); | |
| await new Promise(r => entry.server.close(() => r())); | |
| } | |
| }, | |
| }), | |
| ], | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment