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
What It Looks Like
Section titled “What It Looks Like”╔══════════════════════════════════════════════════════════════════╗║ 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 ║╚══════════════════════════════════════════════════════════════════╝Project Files
Section titled “Project Files”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, wiringdata.ts — Types and Persistence
Section titled “data.ts — Types and Persistence”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 };}state.ts — Application State
Section titled “state.ts — Application State”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; }}render.ts — Rendering
Section titled “render.ts — Rendering”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);}main.ts — Entry Point
Section titled “main.ts — Entry Point”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:
cd examples && npx tsx module-3/task-manager/main.tsModule 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.