Skip to content

3.5 Project: Task Manager TUI

import { Aside } from ‘@astrojs/starlight/components’;

This is the Module 3 capstone. You will build a complete task manager TUI that combines everything from this module:

  • Layout: header, sidebar (filters), main panel (task list), footer
  • List: keyboard-navigable task list
  • Input: add tasks with a text input dialog
  • Persistence: save/load tasks from a JSON file
╔══════════════════════════════════════════════════════════════════╗
║ Task Manager 5 tasks | 2 done ║
╠══════════════╦═══════════════════════════════════════════════════╣
║ Filters ║ Tasks ║
║────────── ║───────────────────────────────────────────────── ║
║ ▶ All ║ ▶ [ ] Buy groceries (work) ║
║ Active ║ [✓] Write tests (work) ║
║ Done ║ [ ] Call the bank (personal) ║
║ Work ║ [✓] Read chapter 4 (personal) ║
║ Personal ║ [ ] Fix deploy pipeline (work) ║
╠══════════════╩═══════════════════════════════════════════════════╣
║ n:new d:delete space:toggle Tab:switch panel q:quit ║
╚══════════════════════════════════════════════════════════════════╝

Create these files in examples/module-3/task-manager/:

task-manager/
├── data.ts — Task type, load/save JSON
├── state.ts — App state type and reducers
├── render.ts — All rendering logic
├── keymap.ts — Key → action mapping
└── main.ts — Entry point, wiring
import fs from 'fs';
import path from 'path';
export type Task = {
id: string;
title: string;
done: boolean;
category: 'work' | 'personal';
};
const FILE = path.join(process.cwd(), '.tasks.json');
export function loadTasks(): Task[] {
try {
return JSON.parse(fs.readFileSync(FILE, 'utf8'));
} catch {
return [
{ id: '1', title: 'Buy groceries', done: false, category: 'personal' },
{ id: '2', title: 'Write tests', done: true, category: 'work' },
{ id: '3', title: 'Call the bank', done: false, category: 'personal' },
{ id: '4', title: 'Read chapter 4', done: true, category: 'personal' },
{ id: '5', title: 'Fix deploy pipeline', done: false, category: 'work' },
];
}
}
export function saveTasks(tasks: Task[]): void {
fs.writeFileSync(FILE, JSON.stringify(tasks, null, 2));
}
export function createTask(title: string, category: Task['category']): Task {
return { id: Date.now().toString(), title, done: false, category };
}
import { type Task, loadTasks, saveTasks, createTask } from './data';
export type Filter = 'all' | 'active' | 'done' | 'work' | 'personal';
export type Panel = 'filters' | 'tasks';
export type Mode = 'browse' | 'add-task';
export type AppState = {
tasks: Task[];
filter: Filter;
focusedPanel: Panel;
selectedFilter: number;
selectedTask: number;
mode: Mode;
inputValue: string;
inputCursor: number;
};
const FILTERS: Filter[] = ['all', 'active', 'done', 'work', 'personal'];
export function initialState(): AppState {
return {
tasks: loadTasks(),
filter: 'all',
focusedPanel: 'tasks',
selectedFilter: 0,
selectedTask: 0,
mode: 'browse',
inputValue: '',
inputCursor: 0,
};
}
export function filteredTasks(state: AppState): Task[] {
switch (state.filter) {
case 'active': return state.tasks.filter(t => !t.done);
case 'done': return state.tasks.filter(t => t.done);
case 'work': return state.tasks.filter(t => t.category === 'work');
case 'personal': return state.tasks.filter(t => t.category === 'personal');
default: return state.tasks;
}
}
export function handleKey(state: AppState, keyName: string, raw: string): AppState {
if (state.mode === 'add-task') {
return handleAddTaskKey(state, keyName, raw);
}
switch (keyName) {
case 'tab':
return { ...state, focusedPanel: state.focusedPanel === 'tasks' ? 'filters' : 'tasks' };
case 'up':
if (state.focusedPanel === 'filters') {
return { ...state, selectedFilter: Math.max(0, state.selectedFilter - 1) };
} else {
return { ...state, selectedTask: Math.max(0, state.selectedTask - 1) };
}
case 'down': {
if (state.focusedPanel === 'filters') {
return { ...state, selectedFilter: Math.min(FILTERS.length - 1, state.selectedFilter + 1) };
} else {
const tasks = filteredTasks(state);
return { ...state, selectedTask: Math.min(tasks.length - 1, state.selectedTask + 1) };
}
}
case 'enter':
if (state.focusedPanel === 'filters') {
const newFilter = FILTERS[state.selectedFilter];
return { ...state, filter: newFilter, selectedTask: 0 };
}
return state;
case 'space': {
if (state.focusedPanel !== 'tasks') return state;
const tasks = filteredTasks(state);
const task = tasks[state.selectedTask];
if (!task) return state;
const updated = state.tasks.map(t =>
t.id === task.id ? { ...t, done: !t.done } : t
);
saveTasks(updated);
return { ...state, tasks: updated };
}
case 'd': {
if (state.focusedPanel !== 'tasks') return state;
const tasks = filteredTasks(state);
const task = tasks[state.selectedTask];
if (!task) return state;
const updated = state.tasks.filter(t => t.id !== task.id);
saveTasks(updated);
return {
...state,
tasks: updated,
selectedTask: Math.min(state.selectedTask, updated.length - 1),
};
}
case 'n':
return { ...state, mode: 'add-task', inputValue: '', inputCursor: 0 };
default:
return state;
}
}
function handleAddTaskKey(state: AppState, keyName: string, raw: string): AppState {
switch (keyName) {
case 'escape':
return { ...state, mode: 'browse' };
case 'enter': {
if (!state.inputValue.trim()) return { ...state, mode: 'browse' };
const newTask = createTask(state.inputValue.trim(), 'work');
const updated = [...state.tasks, newTask];
saveTasks(updated);
return { ...state, tasks: updated, mode: 'browse', inputValue: '' };
}
case 'backspace':
if (state.inputCursor === 0) return state;
return {
...state,
inputValue: state.inputValue.slice(0, state.inputCursor - 1) + state.inputValue.slice(state.inputCursor),
inputCursor: state.inputCursor - 1,
};
case 'left':
return { ...state, inputCursor: Math.max(0, state.inputCursor - 1) };
case 'right':
return { ...state, inputCursor: Math.min(state.inputValue.length, state.inputCursor + 1) };
default:
if (raw.length === 1 && raw.charCodeAt(0) >= 32) {
const v = state.inputValue.slice(0, state.inputCursor) + raw + state.inputValue.slice(state.inputCursor);
return { ...state, inputValue: v, inputCursor: state.inputCursor + 1 };
}
return state;
}
}
import chalk from 'chalk';
import ansiEscapes from 'ansi-escapes';
import { drawBox } from '../draw-box';
import { screenPanel, vSplit, hSplit, inset } from '../layout';
import { type AppState, filteredTasks, type Filter } from './state';
const FILTERS: { label: string; value: Filter }[] = [
{ label: 'All', value: 'all' },
{ label: 'Active', value: 'active' },
{ label: 'Done', value: 'done' },
{ label: 'Work', value: 'work' },
{ label: 'Personal', value: 'personal' },
];
export function render(state: AppState) {
const screen = screenPanel();
const [header, body, footer] = vSplit(screen, [{ fixed: 3 }, { fraction: 1 }, { fixed: 3 }]);
const [sidePanel, mainPanel] = hSplit(body, [{ fixed: 20 }, { fraction: 1 }]);
process.stdout.write(ansiEscapes.cursorTo(0, 0));
// ── Header
const doneCount = state.tasks.filter(t => t.done).length;
drawBox({
col: header.col, row: header.row,
width: header.width, height: header.height,
title: 'Task Manager',
borderStyle: 'double',
borderColor: chalk.cyan,
content: [
chalk.dim(`${state.tasks.length} tasks | ${doneCount} done`).padStart(header.width - 4),
],
});
// ── Sidebar (filters)
const sideActive = state.focusedPanel === 'filters';
drawBox({
col: sidePanel.col, row: sidePanel.row,
width: sidePanel.width, height: sidePanel.height,
title: 'Filters',
borderStyle: 'single',
borderColor: sideActive ? chalk.cyan : chalk.dim,
});
const sideInner = inset(sidePanel);
FILTERS.forEach((f, i) => {
const isSelected = i === state.selectedFilter;
const isActive = f.value === state.filter;
const prefix = isSelected && sideActive ? '' : isActive ? '' : ' ';
const text = (prefix + f.label).padEnd(sideInner.width);
process.stdout.write(ansiEscapes.cursorTo(sideInner.col - 1, sideInner.row - 1 + i));
process.stdout.write(
isSelected && sideActive ? chalk.bgCyan.black(text) :
isActive ? chalk.cyan(text) :
chalk.white(text)
);
});
// ── Main panel (task list)
const mainActive = state.focusedPanel === 'tasks';
drawBox({
col: mainPanel.col, row: mainPanel.row,
width: mainPanel.width, height: mainPanel.height,
title: `Tasks (${filteredTasks(state).length})`,
borderStyle: 'single',
borderColor: mainActive ? chalk.cyan : chalk.dim,
});
const mainInner = inset(mainPanel);
const tasks = filteredTasks(state);
const maxVisible = mainInner.height;
tasks.slice(0, maxVisible).forEach((task, i) => {
const isSelected = i === state.selectedTask && mainActive;
const check = task.done ? chalk.green('[✓]') : chalk.dim('[ ]');
const cat = chalk.dim(`(${task.category})`);
const title = task.done ? chalk.dim(task.title) : chalk.white(task.title);
const titleWidth = mainInner.width - 7 - task.category.length - 3;
const row = ` ${check} ${title.slice(0, titleWidth).padEnd(titleWidth)} ${cat}`;
const plain = row.replace(/\x1b\[[0-9;]*m/g, '');
const padded = plain.padEnd(mainInner.width).slice(0, mainInner.width);
process.stdout.write(ansiEscapes.cursorTo(mainInner.col - 1, mainInner.row - 1 + i));
process.stdout.write(isSelected ? chalk.bgBlue(padded) : row);
});
// ── Footer
drawBox({
col: footer.col, row: footer.row,
width: footer.width, height: footer.height,
borderStyle: 'single',
borderColor: chalk.dim,
content: [' n:new d:delete space:toggle Tab:switch panel q:quit'],
});
// ── Add Task dialog (overlay)
if (state.mode === 'add-task') {
const dialogCol = Math.floor(screen.width / 2) - 20;
const dialogRow = Math.floor(screen.height / 2) - 3;
drawBox({
col: dialogCol, row: dialogRow,
width: 40, height: 5,
title: 'New Task',
borderStyle: 'double',
borderColor: chalk.yellow,
});
// Input field
const inputRow = dialogRow + 2;
const inputCol = dialogCol + 2;
const inputWidth = 36;
const { inputValue, inputCursor } = state;
const visible = inputValue.slice(Math.max(0, inputCursor - inputWidth + 1));
process.stdout.write(ansiEscapes.cursorTo(inputCol - 1, inputRow - 1));
process.stdout.write(chalk.dim('> '));
let rendered = '';
for (let i = 0; i < inputWidth - 2; i++) {
const ch = visible[i] ?? ' ';
rendered += i === (inputCursor - Math.max(0, inputCursor - inputWidth + 1))
? chalk.bgWhite.black(ch)
: ch;
}
process.stdout.write(rendered);
process.stdout.write(ansiEscapes.cursorTo(inputCol - 1, inputRow));
process.stdout.write(chalk.dim('Enter: save Esc: cancel'));
}
process.stdout.write(ansiEscapes.eraseDown);
}
import ansiEscapes from 'ansi-escapes';
import { initialState, handleKey } from './state';
import { render } from './render';
import { parseKey } from '../../module-2/01-keypress-parser';
let state = initialState();
function onKey(raw: string) {
const key = parseKey(raw);
if (key.name === 'ctrl+c' || key.name === 'q' && state.mode === 'browse') {
cleanup();
return;
}
state = handleKey(state, key.name, raw);
render(state);
}
function cleanup() {
process.stdin.setRawMode(false);
process.stdout.write(ansiEscapes.cursorShow);
process.stdout.write(ansiEscapes.exitAlternativeScreen);
process.exit(0);
}
process.stdout.write(ansiEscapes.enterAlternativeScreen);
process.stdout.write(ansiEscapes.cursorHide);
process.stdout.write(ansiEscapes.clearScreen);
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.stdout.on('resize', () => render(state));
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', onKey);
render(state);

Run it:

Terminal window
cd examples && npx tsx module-3/task-manager/main.ts

Module 3 complete. You have a working, persistent TUI application built entirely from TypeScript and Node.js primitives.

Module 4 turns these same techniques toward games — where the render loop runs on a timer and the state represents a world that changes on its own.