import { get, post } from "./api.js"; import { errorText, h, replace, showAlert } from "./dom.js"; import { focusLead } from "./panes.js"; import { summaryMark } from "./project.js"; import { activeTasks, findTask, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, openSummary, state, taskState, unreadSummary } from "./state.js"; import { confirmMerge, models } from "./task.js"; import { taskStyle } from "./taskcolor.js"; export function bindShortcuts() { addEventListener("keydown", (e) => { if (!(e.metaKey || e.ctrlKey) || document.querySelector("dialog[open]")) { return; } if (e.key === "k") { e.preventDefault(); openSwitcher(); } else if (e.key === "p") { e.preventDefault(); focusTab("project"); } else if (e.key === "l") { e.preventDefault(); focusLead(); } else if (e.key === "s") { e.preventDefault(); if (state.selectedTaskId) { void confirmMerge(state.selectedTaskId); } } else if (e.key === "/") { e.preventDefault(); showShortcuts(); } }); } const shortcuts = [ [ "Anywhere", [ ["⌘K", "Switch task, or create one"], ["⌘P", "Open the Project tab"], ["⌘L", "Open the Lead tab and its composer"], ["⌘S", "Merge the right pane's task"], ["⌘/", "Show these shortcuts"], ["Esc", "Close the switcher or a dialog"], ], ], [ "Task switcher", [ ["↑ ↓", "Move the highlight"], ["Enter", "Pick the highlighted row"], ["name: title", "Create the task in the project whose name starts with name"], ["Esc", "Back from the workflow list to the task list"], ], ], [ "Composer and inbox answers", [ ["Enter", "Send"], ["Shift+Enter", "New line"], ["Enter / Space", "Pick the focused choice"], ], ], [ "Dialogs", [["Enter", "Confirm"]], ], [ "Terminal", [["Ctrl+…", "Goes to the shell, not to byre"]], ], ]; function showShortcuts() { void showAlert([ h("h2", { class: "shortcuts-title" }, "Keyboard shortcuts"), ...shortcuts.map(([group, rows]) => h("section", { class: "shortcuts-group" }, h("h3", {}, group), h("dl", {}, ...rows.flatMap(([keys, what]) => [h("dt", {}, h("kbd", {}, keys)), h("dd", {}, what)])))), h("p", { class: "hint" }, "Ctrl works in place of ⌘."), ]); } export function openSwitcher() { if (document.querySelector(".switcher")) { return; } const placeholder = "Go to a task, or type a title to create one…"; const input = h("input", { placeholder, autocomplete: "off" }); const list = h("ul", { class: "switcher-list" }); const backdrop = h("div", { class: "switcher-backdrop" }); const box = h("div", { class: "switcher" }, input, list); let selected = 0; let rows = []; let picking = null; const offs = onEach(["projects", "tasks", "sessions", "inbox", "summaries"], () => render()); const onEscape = (e) => { if (e.key !== "Escape") { return; } e.preventDefault(); e.stopPropagation(); if (picking) { input.value = picking.typed; input.placeholder = placeholder; picking = null; selected = 0; render(); return; } close(); }; addEventListener("keydown", onEscape, { capture: true }); const close = () => { offs(); removeEventListener("keydown", onEscape, { capture: true }); backdrop.remove(); box.remove(); }; const go = (taskId) => { close(); const task = findTask(taskId); if (state.inbox.some((item) => item.task_id === taskId)) { focusTask(taskId, "inbox"); } else if (task && unreadSummary(task)) { openSummary(task); } else { focusTask(taskId, "transcript"); } }; const create = async (projectId, title, workflow) => { close(); try { const task = await post("/api/projects/{projectId}/tasks", { path: { projectId }, body: { title, workflow } }); go(task.id); } catch (err) { void showAlert(errorText(err)); } }; const pickWorkflow = async (project, title) => { const p = { project, title, typed: input.value, workflows: undefined }; picking = p; input.value = ""; input.placeholder = `Pick a workflow to start in ${project.name}: ${title}`; selected = 0; render(); input.focus(); try { p.workflows = await get("/api/workflows"); } catch (err) { close(); void showAlert(errorText(err)); return; } if (picking === p) { render(); } }; const renderWorkflows = (p) => { const q = input.value.trim().toLowerCase(); rows = (p.workflows ?? []) .filter((w) => `${w.name} ${w.description}`.toLowerCase().includes(q)) .map((w) => { const el = h("li", { class: "new-task" }, h("span", { class: "task-title" }, "Start ", h("span", { class: "workflow-name" }, w.name), " in ", h("span", { class: "project-name" }, p.project.name), ": ", p.title, w.description && h("span", { class: "workflow-description" }, w.description)), h("span", { class: "task-state" }, "workflow")); return { el, pick: () => void create(p.project.id, p.title, w.name) }; }); if (p.workflows === undefined) { return "Loading workflows…"; } return p.workflows.length === 0 ? "No saved workflows." : "No workflow matches."; }; const render = () => { const empty = picking ? renderWorkflows(picking) : renderTasks(); selected = Math.min(selected, Math.max(rows.length - 1, 0)); rows.forEach((row, i) => { row.el.classList.toggle("selected", i === selected); row.el.onmousedown = (e) => { e.preventDefault(); row.pick(); }; }); replace(list, ...rows.map((r) => r.el), rows.length === 0 && h("li", { class: "empty-row" }, empty)); rows[selected]?.el.scrollIntoView({ block: "nearest" }); }; const renderTasks = () => { const typed = input.value.trim(); const prefix = /^([^:\s]+):\s*(.*)$/.exec(typed); const named = prefix ? state.projects.filter((p) => p.name.toLowerCase().startsWith(prefix[1].toLowerCase())) : []; const targets = named.length > 0 ? named : undefined; const title = targets ? prefix[2] : typed; const q = title.toLowerCase(); const inbox = (taskId) => state.inbox.filter((item) => item.task_id === taskId).length; const visits = lastVisits(); const matches = activeTasks() .filter(({ project, task }) => targets ? targets.some((p) => p.id === project.id) && task.title.toLowerCase().includes(q) : `${project.name} ${task.title}`.toLowerCase().includes(q)) .map((m) => ({ ...m, count: inbox(m.task.id), visited: m.task.id === state.selectedTaskId ? Infinity : (visits[m.task.id] ?? 0) })) .sort((a, b) => Number(b.count > 0) - Number(a.count > 0) || a.visited - b.visited); rows = matches.map(({ project, task, count }) => { const ts = taskState(task); const el = h("li", { style: taskStyle(task.id) }, h("span", { class: `dot ${ts.kind}`, title: ts.title }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", task.parent_task_id !== undefined && h("span", { class: "subtask-mark" }, `${findTask(task.parent_task_id)?.title ?? task.parent_task_id} ↳ `), h("span", { class: "task-name" }, task.title)), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), summaryMark(task), h("span", { class: `task-state ${ts.kind}`, title: ts.title }, ts.label)); return { el, pick: () => go(task.id) }; }); for (const p of (targets ?? state.projects).filter((p) => `${p.name} project`.toLowerCase().includes(q))) { const el = h("li", {}, h("span", { class: "dot none" }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, p.name)), h("span", { class: "task-state" }, "project")); rows.push({ el, pick: () => { close(); focusProject(p.id); }, }); } const fallback = focusedProject() ?? state.projects[0]; for (const project of title ? (targets ?? (fallback ? [fallback] : [])) : []) { const el = h("li", { class: "new-task" }, h("span", { class: "task-title" }, "New task in ", h("span", { class: "project-name" }, project.name), ": ", title), h("span", { class: "task-state", title: "Model for the first message" }, models[0])); rows.push({ el, pick: () => void create(project.id, title) }); const wf = h("li", { class: "new-task" }, h("span", { class: "task-title" }, "Start workflow… in ", h("span", { class: "project-name" }, project.name), ": ", title), h("span", { class: "task-state" }, "workflow")); rows.push({ el: wf, pick: () => void pickWorkflow(project, title) }); } return "No active tasks. Type a title to create one."; }; input.oninput = () => { selected = 0; render(); }; input.onkeydown = (e) => { if (e.key === "ArrowDown") { e.preventDefault(); selected = Math.min(selected + 1, rows.length - 1); render(); } else if (e.key === "ArrowUp") { e.preventDefault(); selected = Math.max(selected - 1, 0); render(); } else if (e.key === "Enter") { e.preventDefault(); rows[selected]?.pick(); } }; backdrop.onclick = close; const anchor = document.querySelector(".task-picker").getBoundingClientRect(); box.style.top = `${anchor.bottom + 4}px`; document.body.append(backdrop, box); const viewport = document.documentElement.clientWidth; const left = anchor.left + anchor.width / 2 - box.offsetWidth / 2; box.style.left = `${Math.max(8, Math.min(left, viewport - box.offsetWidth - 8))}px`; render(); input.focus(); } //# sourceMappingURL=switcher.js.map