A keyboard-driven task manager that lives entirely in the terminal with zero dependencies beyond libc, ncurses, libsqlite3, and libuuid.
The implementation is C11 across four translation units, and most of it lives in src/tui.c: multi-panel navigation, form interactions, dynamic resizing traps, and the ncurses lifecycle.
Memory Ownership & The free(ptr - 4) Bug
In high-level languages, strings carry lengths and garbage collectors clean up unreferenced memory. In C, memory ownership dictates architecture.
ncurses enforces strict ownership constraints: when instantiating a menu item with new_item(name, description), the library stores raw pointers directly (item->name.str = name) without copying strings. The string rendered on screen and the field inside the backing Task struct share the exact same allocation. Freeing or mutating the struct while the menu is posted leaves dangling pointers that corrupt memory on the next redraw.
To eliminate redundant allocator overhead, status checkboxes and task titles are fused into a single heap buffer:
Task.titleis allocated withmalloc(mark_len + title_len + 1), reserving four bytes for the"[ ] "or"[X] "prefix alongside the string and null terminator.new_item()takes this base pointer directly, allowingncursesto render the checkbox and text as a single label without secondary string formatting allocations.
The tradeoff surfaces during edits and writes: an input form or SQL update must receive Buy milk, not [ ] Buy milk.
Early in development, the codebase attempted to handle this by mutating the struct pointer in-place with task->title = task->title + 4, followed by a fragile attempt to recover the original base address using free(task->title - 4).
// OLD CODE: Destructive pointer arithmetic
task->title = task->title + 4;
...
free(task->title - 4); // Fragile attempt to recover original allocation base
When this brittle pointer arithmetic led to corrupted heaps and double-frees, it forced a strict discipline: an owning pointer has exactly one value across its entire lifecycle. Non-owning views are now held in separate const char * variables (displayed_title = task->title + 4 in forms, clean_title = task->title + 4 before sqlite3_bind_text) that are never passed to free(). In addition, free_task_field() explicitly zeroes title, desc, and tag pointers to NULL to guard against double-free hazards across rebuild cycles.
TUI Mechanics & Buffer Sanitization
The layout requires at least 70 columns by 27 rows. Rather than rendering garbled components when a terminal is shrunk below threshold, handle_resize() traps execution in a quarantine loop displaying a centered warning banner until a subsequent KEY_RESIZE event signals that dimensions have returned to safe bounds.
Screen inputs are bridged to storage via trim_fieldbuf() in src/utils.c:
ncursesform fields pad user input with trailing whitespace out to the full field width (50 spaces for titles, 30 for tags).- Without sanitization, empty fields register as non-empty (
strlen > 0), and saved records accumulate excessive whitespace. trim_fieldbuf()strips leading and trailing whitespace, carriage returns, and newlines before allocating an exactmalloc(len + 1)buffer for SQLite persistence.
Storage & Transactional Boundaries
Persistence runs on SQLite across three tables: tasks, tags, and a task_tags join table using composite primary keys. UUIDs are generated via libuuid and stored as 36-byte text strings.
(A notable early bug involved using sqlite3_bind_text16 instead of sqlite3_bind_text on the ASCII UUID buffer. SQLite interpreted 1-byte ASCII characters as 2-byte UTF-16 code units, writing CJK/Chinese characters like 摤㠷... directly into the database).
- Soft Deletes & Foreign Keys: Records are never dropped via
DELETE. Deletes execute asUPDATEsettingis_deleted = 1. While the schema definesON DELETE CASCADE,PRAGMA foreign_keys = ON;is never issued duringdb_open(), leaving cascading constraints disabled by default in SQLite. - Two-Pass Allocation:
load_tasks_fillterd()queries SQLite twice: first withSELECT COUNT(DISTINCT t.id)to allocate an exactTask[]array withcalloc(), followed by the fetch query. Ifsqlite3_step()fails mid-stream, an error cleanup loop callsfree_task_field()on every previously populated element to prevent partial-fetch leaks. - Transaction Rollbacks: Inserting a task alongside its tags runs within a single SQLite transaction, with an explicit rollback on every failure path. One of those paths has a logic flaw: when an existing tag is resolved (
rc == SQLITE_ROW), checkingif (rc != SQLITE_DONE)triggers an unintended rollback on existing tags. In contrast,update_task()sends every error site through onegoto rollback;exit.
View Invalidation & Teardown Order
Rather than calculating visual diffs, every mutating keystroke tears down the active task menu and re-executes a clean query against SQLite. That covers toggling a task, modifying a tag, and updating an entry.
Because ITEM references hold raw pointers into strings owned by Task structs, teardown order is load-bearing:
item_index(current_item())captures the active cursor index.unpost_menu()detaches the menu from the screen.set_menu_items(menu, NULL)disconnects the item array;ncursesrejects freeing items that remain bound to a menu.- Individual
ITEMhandles are released viafree_item(). - The
itemspointer array is freed. free_task_field()deallocates string fields beforefree(task_list)releases the struct array.load_tasks_fillterd()re-queries the database, builds fresh items, posts the menu, and restores the saved cursor index.
Query Splicing & Known Limits
Filter states are expressed as raw SQL fragments spliced directly into query WHERE clauses (1=1, t.status = 1, or date-bounded ranges). This design creates three runtime edge cases:
- Tag Filter Truncation: Tag filters allocate a 31-byte heap buffer via
malloc(31)and format the clause withsnprintf(..., 31, "tg.name='%s'", tag_name). Because the SQL boilerplate consumes 11 bytes, tags exceeding 20 characters lose their closing quote ('), producing malformed SQL that breaks the query outright. Unescaped apostrophes inside tag names similarly corrupt query syntax. - Timezone Offset Drift: Filters evaluate
date(due_date, 'unixepoch')againstdate('now')directly in SQLite. Because SQLite calculatesdate('now')in UTC while the TUI formats display timestamps using local system time (localtime()), users in positive UTC offsets experience a desynchronization window after midnight where tasks display yesterday’s dates. - Empty State Inference: Empty state labels (“No tasks for today”) are determined by running
strstr()searches against the active SQL string rather than inspecting an explicit query enum.
Build System & Tooling
The build harness uses a modular GNU Makefile supporting specialized profiles and automated diagnosis:
- Sanitizers & Profiles: Configured with
make debug(-O0 -g -DDEBUG),make release(-O2 -DNDEBUG), andmake run-asan, which compiles with AddressSanitizer and writes logs directly to$(LOG_DIR)/asan.log. - Leak Detection: Automated
make valgrindtarget running non-interactive profiling with--leak-check=full --track-origins=yeswriting tovalgrind.log. - Crash Diagnostics: Headless
make gdb-reportandmake lldb-reporttargets run non-interactive batch debugging sessions that automatically dump stack backtraces to log files on abnormal termination. - Platform Portability: Header dependencies track automatically via
-MMD -MP. Platform branching in the Makefile detects Darwin systems to resolveuuiddirectly from macOS system headers while falling back topkg-config uuidon Linux.