Todoist Show Nav Shortcuts v1.0.0
This user script permanently displays keyboard shortcuts next to the main navigation items in Todoist's left sidebar (Search, Inbox, Today, Upcoming, Filters & Labels). Todoist normally only shows these shortcuts in a hover tooltip, making them hard to discover and learn. The shortcuts are styled as keyboard-key badges and positioned on the right-hand side of each navigation item, consistent with Todoist's own tooltip styling.
Script Content
// ==UserScript==
// @name Todoist: Show Navigation Shortcuts
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.0.0
// @description Permanently displays keyboard shortcuts next to navigation items in Todoist's left sidebar
// @author Tim Hilton using Copilot
// @match https://app.todoist.com/app/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const LOG_PREFIX = '[Todoist: Show Navigation Shortcuts]';
const BADGE_CLASS = 'tjhleeds-nav-shortcut';
let observerCallCount = 0;
console.debug(`${LOG_PREFIX} Script initialized`);
// Keyboard shortcut definitions for Todoist navigation items (Windows / web).
// keys: array of key groups.
// - One group = keys pressed simultaneously, e.g. [['Ctrl', 'K']] renders [Ctrl][K].
// - Multiple groups = sequential presses, e.g. [['G'], ['I']] renders [G] then [I].
//
// Matchers use terminal-anchor regexes (ending with $) so that, for example,
// /app/filters/12345 (an individual filter page) does NOT match the
// "Filters & Labels" nav item whose href is /app/filters or /app/filters/.
// Text-content matching is intentionally avoided to prevent false positives on
// custom filters with names like "Today non work" or "Upcoming weekend".
const NAV_SHORTCUTS = [
{
name: 'Search',
keys: [['Ctrl', 'K']],
// Exact aria-label match only — avoids picking up unrelated buttons that
// contain the word "search" somewhere in a longer label.
matchers: [
(el) => /^(open\s+)?quick\s*find$/i.test((el.getAttribute('aria-label') ?? '').trim()),
(el) => /^search$/i.test((el.getAttribute('aria-label') ?? '').trim()),
],
},
{
name: 'Inbox',
keys: [['G'], ['I']],
matchers: [(el) => /\/app\/inbox\/?$/.test(el.getAttribute('href') ?? '')],
},
{
name: 'Today',
keys: [['G'], ['T']],
matchers: [(el) => /\/app\/today\/?$/.test(el.getAttribute('href') ?? '')],
},
{
name: 'Upcoming',
keys: [['G'], ['U']],
matchers: [(el) => /\/app\/upcoming\/?$/.test(el.getAttribute('href') ?? '')],
},
{
name: 'Filters & Labels',
keys: [['G'], ['V']],
// /app/filters or /app/filters/ but NOT /app/filters/12345.
matchers: [(el) => /\/app\/filters\/?$/.test(el.getAttribute('href') ?? '')],
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Return true if the element matches any of the shortcut's matchers. */
function matchesShortcut(el, shortcutDef) {
return shortcutDef.matchers.some((matcher) => {
try {
return matcher(el);
} catch {
return false;
}
});
}
/**
* Return true if the element lives inside the main page content area or a
* breadcrumb, rather than in the left sidebar. Used to exclude breadcrumb
* links that share the same href as a main nav item (e.g. "Filters & Labels"
* in the page header) and controls embedded in task-list section headers.
*/
function isInMainContent(el) {
return !!(
el.closest('[role="main"]') ||
el.closest('main') ||
el.closest('header') ||
el.closest('[class*="view_header"]') ||
el.closest('[class*="top_bar"]') ||
el.closest('[class*="topbar"]') ||
el.closest('[class*="breadcrumb"]') ||
el.closest('[aria-label*="breadcrumb" i]')
);
}
/**
* Build a styled shortcut badge element.
* Each key group is separated by "then"; keys within a group are adjacent
* (like Todoist's own tooltip styling).
*/
function createShortcutBadge(keys) {
const container = document.createElement('span');
container.className = BADGE_CLASS;
container.setAttribute('aria-hidden', 'true');
Object.assign(container.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
marginLeft: 'auto',
flexShrink: '0',
paddingLeft: '8px',
pointerEvents: 'none',
});
keys.forEach((keyGroup, groupIndex) => {
if (groupIndex > 0) {
const sep = document.createElement('span');
sep.textContent = 'then';
Object.assign(sep.style, {
fontSize: '10px',
color: 'rgba(255,255,255,0.45)',
margin: '0 2px',
fontFamily: 'inherit',
});
container.appendChild(sep);
}
keyGroup.forEach((key) => {
const kbd = document.createElement('kbd');
kbd.textContent = key;
Object.assign(kbd.style, {
display: 'inline-block',
padding: '1px 4px',
border: '1px solid rgba(255,255,255,0.25)',
borderRadius: '3px',
fontSize: '11px',
fontFamily: 'inherit',
lineHeight: '1.4',
color: 'rgba(255,255,255,0.65)',
background: 'rgba(255,255,255,0.07)',
minWidth: '16px',
textAlign: 'center',
boxShadow: '0 1px 0 rgba(255,255,255,0.15)',
fontWeight: 'normal',
});
container.appendChild(kbd);
});
});
return container;
}
/** Inject a shortcut badge into a nav element (idempotent). */
function injectShortcutBadge(navEl, shortcutDef) {
if (navEl.querySelector(`.${BADGE_CLASS}`)) {
console.debug(`${LOG_PREFIX} Badge already present for: ${shortcutDef.name}`);
return;
}
console.debug(`${LOG_PREFIX} Injecting shortcut badge for: ${shortcutDef.name}`);
navEl.appendChild(createShortcutBadge(shortcutDef.keys));
console.log(`${LOG_PREFIX} ✅ Shortcut badge added for: ${shortcutDef.name}`);
}
// ---------------------------------------------------------------------------
// Main processing
// ---------------------------------------------------------------------------
function processNavItems() {
console.debug(`${LOG_PREFIX} Processing navigation items`);
// Build a deduplicated list of candidate elements using targeted selectors.
// The generic "nav a / nav button" fallback has been intentionally removed
// because it also selects custom filter items in the sidebar and controls
// embedded in task-list section headers.
const seen = new Set();
const candidates = [
// Main navigation links (exact path only — terminal $ in matchers prevents
// /app/today-something or /app/filters/12345 from matching).
...document.querySelectorAll(
'a[href*="/app/inbox"], a[href*="/app/today"], a[href*="/app/upcoming"], a[href*="/app/filters"]'
),
// Search is a button in the sidebar — select only buttons with an aria-label.
...document.querySelectorAll('button[aria-label], [role="button"][aria-label]'),
].filter((el) => {
// Exclude anything inside the main content area or breadcrumb so that
// breadcrumb links sharing the same href as a nav item are not matched,
// and so that buttons embedded in task-list headers are not matched.
if (isInMainContent(el)) {
console.debug(`${LOG_PREFIX} Excluding element in main content: ${el.tagName} href=${el.getAttribute('href') ?? ''} label=${el.getAttribute('aria-label') ?? ''}`);
return false;
}
if (seen.has(el)) return false;
seen.add(el);
return true;
});
console.debug(`${LOG_PREFIX} ${candidates.length} candidate element(s) found`);
let matched = 0;
for (const el of candidates) {
for (const shortcutDef of NAV_SHORTCUTS) {
if (matchesShortcut(el, shortcutDef)) {
injectShortcutBadge(el, shortcutDef);
matched++;
break;
}
}
}
console.debug(`${LOG_PREFIX} Matched ${matched} navigation item(s)`);
}
// ---------------------------------------------------------------------------
// Observers
// ---------------------------------------------------------------------------
// Run immediately in case the sidebar is already in the DOM.
processNavItems();
// Watch for DOM changes (Todoist is a React SPA and renders asynchronously).
const domObserver = new MutationObserver((mutations) => {
observerCallCount++;
console.debug(`${LOG_PREFIX} MutationObserver fired (count: ${observerCallCount})`);
const hasAdditions = mutations.some(
(m) => m.type === 'childList' && m.addedNodes.length > 0
);
if (hasAdditions) {
processNavItems();
}
});
// Prefer observing the sidebar element for better performance; fall back to body.
const sidebarSelectors = ['nav', '[role="navigation"]', 'aside', '#navigation', '.navigation'];
let observationRoot = null;
for (const sel of sidebarSelectors) {
observationRoot = document.querySelector(sel);
if (observationRoot) {
console.debug(`${LOG_PREFIX} Observing sidebar element: ${sel}`);
break;
}
}
if (!observationRoot) {
console.debug(`${LOG_PREFIX} No sidebar element found yet — observing document.body`);
observationRoot = document.body;
}
domObserver.observe(observationRoot, { childList: true, subtree: true });
// Re-process after client-side navigation (URL changes).
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
console.debug(`${LOG_PREFIX} URL changed to ${lastUrl} — re-processing navigation`);
setTimeout(processNavItems, 500);
}
}).observe(document, { subtree: true, childList: true });
})();