One Task List Everywhere: Adding a Global Inbox to Your Terminal Task Manager
This is the third post in a series about building a plain-text task manager that fits the way developers actually work.
- Part 1: Why Plain Text Task Management Is the Right Move in the AI Age — introduced the
tasks-clitool and the case forTODO.mdfiles next to your code. - Part 2: Build a Global Hotkey Daemon in Node.js — added a system-wide
Ctrl+Spaceshortcut to capture tasks without switching windows. - Part 3 (this post): Adding a global inbox that’s always visible in the TUI, no matter which project is selected.
The Gap
After Part 2, you can press Ctrl+Space from anywhere on your desktop and type a task. The daemon appends it to ~/TODO.md. That part works.
The problem: when you open tasks, that global inbox isn’t visible unless you’ve navigated to your home directory in the sidebar. It’s easy to forget it exists.
What we actually want: a Global entry always pinned at the top of the project list, in a distinct color, showing your folderless inbox alongside your project-local tasks.
What We’re Building
By the end of this post, the tasks TUI will look like this:
┌─ Projects ──────────────┐
│ 🌐 Global (3) │
│ 📌 tasks-cli (1) │
│ antigravityapps │
│ efficientcoder2 (2) │
└─────────────────────────┘
Global always appears first. Selecting it shows your ~/TODO.md tasks. Pressing a while it’s selected adds to ~/TODO.md, not to any project file.
Three files change. Total additions: about 25 lines.
Prerequisites
You should have the tasks-cli codebase from Part 1. The repo is at github.com/techiediaries/tasks-cli. Clone it and install dependencies:
git clone https://github.com/techiediaries/tasks-cli.git
cd tasks-cli
npm install
npm link # makes `tasks` available system-wide
The file structure you care about:
lib/
config.js ← reads/writes ~/.config/tasks/config.json
discover.js ← finds projects by scanning roots for TODO.md
tui.js ← the blessed TUI
Step 1 — Add globalFile to the Config
Open lib/config.js. The defaults() function returns the baseline config when no config.json exists yet:
function defaults() {
return { roots: getDefaultRoots(), pinned: [], initialized: false };
}
Add globalFile pointing to ~/TODO.md:
import os from 'os';
function defaults() {
return {
roots: getDefaultRoots(),
pinned: [],
initialized: false,
globalFile: path.join(os.homedir(), 'TODO.md'),
};
}
This does two things. First, it gives loadConfig() a reliable place to read the path from — no hardcoding scattered through the codebase. Second, because loadConfig() merges saved config over defaults, any existing config.json that doesn’t have a globalFile key will automatically get the default on next read. Existing users aren’t broken.
If someone wants to use a different path — say ~/work/INBOX.md — they can set it once in ~/.config/tasks/config.json and everything picks it up.
Step 2 — Synthesize the Global Project
Open lib/discover.js. This file already contains findProjects(), which scans your configured roots for directories containing TODO.md. We’re not changing that function. We’re adding a new one that returns a single synthetic project object for the global file.
Add this import at the top:
import os from 'os';
Then add the function after findProjects():
export function findGlobalProject() {
const { globalFile } = loadConfig();
const file = globalFile || path.join(os.homedir(), 'TODO.md');
return { name: 'Global', dir: path.dirname(file), pinned: false, isGlobal: true };
}
The key insight here: we’re not creating a new kind of project object. We’re returning the same shape — { name, dir, pinned } — that every real project already has, plus one extra flag isGlobal: true. The rest of the codebase already knows how to render and write projects using this shape.
The dir field deserves attention. The existing todoPath() function works like this:
export function todoPath(dir) { return path.join(dir, 'TODO.md'); }
If globalFile is ~/TODO.md, then path.dirname('~/TODO.md') is ~, and path.join('~', 'TODO.md') gives us back ~/TODO.md. We get the right path for free — no change to todoPath() needed.
One potential collision: what if the home directory is already in roots and findProjects() discovers a TODO.md there? The home directory is not in the default roots list:
const CANDIDATE_ROOTS = [
'Documents', 'Desktop', 'Projects', 'dev', 'code',
'workspace', 'src', 'work', 'antigravityapps',
];
No home directory. No collision.
Step 3 — Wire It Into the TUI
Open lib/tui.js.
3a. Import findGlobalProject
import { findProjects, findGlobalProject, todoPath, notesPath, readFile, writeFile } from './discover.js';
3b. Replace the startup logic
Find the top of launchTui():
let projects = findProjects();
if (!projects.length) {
console.error('No projects found. Run `tasks init` inside a project directory first.');
process.exit(1);
}
Replace it with:
const globalProject = findGlobalProject();
let projects = [globalProject, ...findProjects()];
We remove the “no projects found” guard entirely. Even if you have zero local projects, Global is always there. The TUI should never refuse to open.
3c. Update refresh()
The refresh() function is called any time a file changes on disk or the user presses r. It re-reads the project list from disk. Find it:
function refresh() {
projects = findProjects();
...
}
Update it:
function refresh() {
projects = [globalProject, ...findProjects()];
...
}
globalProject is defined once at the top of launchTui() and reused on every refresh. No re-reading config on every keystroke.
3d. Style Global differently in the sidebar
Find refreshSidebar():
function refreshSidebar() {
const items = projects.map(p => {
const todo = readFile(todoPath(p.dir));
const count = todo ? parseTasks(todo).current.length : 0;
const pin = p.pinned ? '📌 ' : ' ';
const badge = count > 0 ? ` {#6366f1-fg}(${count}){/}` : '';
return `${pin}{bold}${p.name}{/}${badge}`;
});
Add the isGlobal branch before the pin line:
function refreshSidebar() {
const items = projects.map(p => {
const todo = readFile(todoPath(p.dir));
const count = todo ? parseTasks(todo).current.length : 0;
const badge = count > 0 ? ` {#6366f1-fg}(${count}){/}` : '';
if (p.isGlobal) {
return `🌐 {bold}{#f59e0b-fg}${p.name}{/}${badge}`;
}
const pin = p.pinned ? '📌 ' : ' ';
return `${pin}{bold}${p.name}{/}${badge}`;
});
Amber (#f59e0b) distinguishes Global from regular projects (indigo) and pinned projects (the 📌 icon). The 🌐 emoji makes it immediately obvious at a glance.
3e. Style the task panel header
In renderTasks(), find the project name line:
lines.push(`\n {bold}{white-fg}${proj.pinned ? '📌 ' : ''}${proj.name}{/}\n`);
Replace it with:
const nameTag = proj.isGlobal
? `\n 🌐 {bold}{#f59e0b-fg}${proj.name}{/} {#475569-fg}(~/TODO.md){/}\n`
: `\n {bold}{white-fg}${proj.pinned ? '📌 ' : ''}${proj.name}{/}\n`;
lines.push(nameTag);
The (~/TODO.md) annotation makes the file location explicit. This matters when you’re looking at a task and want to know where it lives on disk — especially useful when you open a PR and realize a task belongs in a project file, not the global inbox.
Try It
tasks
Global should appear at the top of the sidebar. Navigate to it with j/k (or arrow keys), then press a to add a task. If ~/TODO.md doesn’t exist yet, it will be created automatically on first write — the existing writeFile + initTodo path handles that.
To verify the file was written:
cat ~/TODO.md
You should see:
# TODO — Global
## Current
- [ ] your task here
## Backlog
## Done
How the Daemon Connects
From Part 2, the hotkey daemon appends tasks to ~/TODO.md:
// lib/capture.js (daemon side)
const GLOBAL_TODO = path.join(os.homedir(), 'TODO.md');
function appendTask(text) {
writeFile(GLOBAL_TODO, addTask(readFile(GLOBAL_TODO) ?? initTodo('Global'), text));
}
The TUI watches the filesystem with chokidar. When the daemon writes to ~/TODO.md, the watcher fires, refresh() is called, and the Global section updates immediately. No manual reload.
The full loop:
- Press
Ctrl+Spacefrom anywhere → daemon captures text → writes to~/TODO.md tasks(already open in a terminal) — Global badge updates automatically- Navigate to Global, select the task, triage it: promote to a project, or mark done
Configuring a Different Global Path
If you want ~/work/INBOX.md instead of ~/TODO.md, edit ~/.config/tasks/config.json:
{
"globalFile": "/home/you/work/INBOX.md"
}
Restart tasks. The Global entry now points there. The daemon also reads this config path via loadConfig(), so both sides stay in sync automatically.
What This Pattern Teaches
The interesting design move here isn’t the feature — it’s the shape of the solution.
The TUI already knew how to render and write a project. We didn’t add a new concept; we created a synthetic project object with the same shape as a real one, plus one flag (isGlobal). The only special-case code is in the rendering layer — two if (p.isGlobal) branches — and everything else (reads, writes, parseTasks, initTodo) runs on the global file exactly as it would on a project file.
This is worth internalizing as a general pattern: when you need to extend a list-based UI with a special first item, fitting the special item into the existing data shape is almost always cleaner than adding a parallel render path.
Summary
Three files, ~25 lines added:
| File | Change |
|---|---|
lib/config.js |
Added globalFile to defaults |
lib/discover.js |
Added findGlobalProject() |
lib/tui.js |
Prepend global project; two isGlobal render branches |
The result: ~/TODO.md is always visible at the top of your task manager, in amber, updated live as the hotkey daemon writes to it. Folderless tasks finally have a home.
What’s Next
The series is functionally complete. A few natural extensions if you keep building:
- Triage mode (
tkey) — move a task from Global to a specific project without leaving the TUI - Due dates — parse
YYYY-MM-DDin task text and sort/highlight accordingly tasks add "text"— append to global inbox from the command line without opening the TUI
The full source is at github.com/techiediaries/tasks-cli, branch feat/daemon-capture.
-
Date:
