Skip to content

Instantly share code, notes, and snippets.

@Keboo
Created June 16, 2026 07:07
Show Gist options
  • Select an option

  • Save Keboo/7db90100e73146fddbc2fd2de06027e5 to your computer and use it in GitHub Desktop.

Select an option

Save Keboo/7db90100e73146fddbc2fd2de06027e5 to your computer and use it in GitHub Desktop.
Copilot extension: todo-list
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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>&#x1F4CB; TODO List</h1>
<div class="add-row">
<input type="text" id="new-todo" placeholder="Add a new task&hellip;" 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