Building Tosion: A Terminal-Based Note-Taking Application in Go
This chapter covers how to build Tosion (a terminal-based Notion clone), a highly interactive Terminal User Interface (TUI) application using Go and the Bubble Tea framework. You will learn how to manage state, handle keyboard events, interact with the local file system, render styled text, and build complex UI components like text inputs, text areas, and paginated lists directly inside your terminal.
1. Introduction to Terminal User Interfaces (TUIs) and Tosion
A Terminal User Interface (TUI) brings the interactivity of graphical web applications—such as forms, menus, buttons, and lists—directly into the command-line environment. Instead of executing a single command and exiting, a TUI application runs an interactive loop that captures user input in real-time and dynamically updates the terminal screen.
Real-Life Analogy
Think of a standard Command Line Interface (CLI) like a vending machine: you input a specific code (a command with flags), press enter, and it drops a single item (the output) before stopping. A TUI, on the other hand, is like an interactive ATM screen: it stays open, presents a visual menu, allows you to navigate options using arrow keys, and updates the display dynamically based on your selections.
Project Overview: Tosion
Tosion is a terminal-based personal note-taking application inspired by Notion. It stores notes as Markdown files in a dedicated local directory and provides the following interactive features:
- Create Notes (
Ctrl+N): Prompts the user for a filename and initializes a new Markdown file. - List Notes (
Ctrl+L): Renders a paginated, searchable list of all saved notes. - Edit Notes (
Enter): Opens a full-screen text area to write and modify Markdown content. - Save Notes (
Ctrl+S): Persists the modified text back to the local file system. - Filter Notes (
/): Searches through saved notes in real-time.
2. The Elm Architecture: Model-View-Update (MVU) Pattern
The Bubble Tea framework is strictly based on the Elm Architecture (also known as the Model-View-Update pattern). This design pattern provides a clean, unidirectional data flow that makes state management predictable and easy to debug.
The Three Pillars of MVU
- Model: A struct that represents the entire state of your application (e.g., active inputs, list of notes, current screen, cursor position).
- Update: A function that handles incoming events (called "messages" or
msgin Bubble Tea, such as keypresses, window resizes, or timer ticks). It modifies the Model and can optionally return a Command (Cmd) to trigger asynchronous side effects. - View: A pure function that inspects the current Model and returns a single string representing the UI to be rendered on the terminal screen.
Unidirectional Data Flow
3. Project Initialization and Makefile Automation
To build a robust Go application, we begin by initializing a Go module and setting up a Makefile to automate compilation and execution workflows.
Step 1: Initialize the Go Module
Run the following commands in your terminal to create a project directory and initialize the module:
mkdir tosion
cd tosion
go mod init github.com/yourusername/tosion
Step 2: Create the Makefile
To avoid typing repetitive build and run commands, create a Makefile in the root directory. This file automates compiling the Go binary and executing it in one step.
.PHONY: build run
build:
@go build -o tosion .
run: build
@./tosion
Note: The
@symbol before commands in theMakefilesuppresses the output of the command itself in the terminal, showing only the application's actual output.
4. Setting Up the Bubble Tea Lifecycle
Every Bubble Tea application requires a core entry point that binds the Model, Update, and View components together.
Step 1: Install Bubble Tea
Run the following command to add the Bubble Tea library to your module dependencies:
go get github.com/charmbracelet/bubbletea
Step 2: Implement the Basic Lifecycle Code
Create a main.go file and set up the minimal boilerplate required to run a Bubble Tea application:
package main
import (
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
)
// 1. Define the Model
type model struct {
message string
}
// 2. Initialize the Model
func initialModel() model {
return model{
message: "Welcome to Tosion!",
}
}
// 3. The Init Method (for initial I/O commands)
func (m model) Init() tea.Cmd {
return nil
}
// 4. The Update Method (handles events)
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
return m, nil
}
// 5. The View Method (renders the UI)
func (m model) View() string {
return m.message + "\n\nPress 'q' or 'Ctrl+C' to exit."
}
func main() {
p := tea.NewProgram(initialModel())
if _, err := p.Run(); err != nil {
fmt.Printf("Alas, there's been an error: %v", err)
os.Exit(1)
}
}
5. Designing and Styling the Header with Lip Gloss
To make our terminal application visually appealing, we use Lip Gloss, a CSS-like styling library built specifically for terminal applications.
Step 1: Install Lip Gloss
go get github.com/charmbracelet/lipgloss
Step 2: Define Styles and Render the Header
We will define a styled header block with a custom background color, foreground text color, and padding.
package main
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
// Define terminal ANSI colors
var (
headerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("16")). // Black text
Background(lipgloss.Color("205")). // Hot pink background
PaddingLeft(2).
PaddingRight(2).
Bold(true)
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")) // Gray text
)
func renderHeader() string {
welcome := headerStyle.Render("WELCOME TO TOSION 🧠")
help := helpStyle.Render("Ctrl+N: New Note • Ctrl+L: List Notes • Esc: Back • Ctrl+S: Save • Ctrl+Q: Quit")
return fmt.Sprintf("\n%s\n\n%s\n", welcome, help)
}
6. Implementing Note Creation (Ctrl+N) with Text Input
To create a new note, we need to capture a filename from the user. We will use the textinput component from the Charm Bubbles library.
Step 1: Install Bubbles
go get github.com/charmbracelet/bubbles
Step 2: Update the Model and Initialization
We add the text input component and a visibility flag to our application state.
import (
"github.com/charmbracelet/bubbles/textinput"
)
type model struct {
newFileInput textinput.Model
createFileVisible bool
// ... other fields
}
func initialModel() model {
ti := textinput.New()
ti.Placeholder = "What would you like to call this note?"
ti.Focus()
ti.CharLimit = 156
ti.Width = 50
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
return model{
newFileInput: ti,
createFileVisible: false,
}
}
Step 3: Handle Key Events and Update Loop
We listen for ctrl+n to show the input box, and forward keyboard events to the textinput component when it is active.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "ctrl+n":
m.createFileVisible = true
m.newFileInput.Focus()
return m, nil
case "esc":
m.createFileVisible = false
m.newFileInput.Blur()
return m, nil
}
}
// Forward keypresses to the text input component if it is visible
if m.createFileVisible {
m.newFileInput, cmd = m.newFileInput.Update(msg)
}
return m, cmd
}
7. File System Integration: Managing the Note Vault
Instead of saving notes in the current working directory (which changes depending on where the user runs the binary), we store all notes in a dedicated directory inside the user's home directory: ~/.tosion/.
Step 1: Initialize the Vault Directory
We use Go's standard init() function to determine the user's home directory and ensure the ~/.tosion/ directory exists before the application starts.
package main
import (
"fmt"
"log"
"os"
"path/filepath"
)
var vaultDir string
func init() {
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("Error retrieving user home directory: %v", err)
}
vaultDir = filepath.Join(home, ".tosion")
// Create the vault directory with read/write/execute permissions for the owner
if err := os.MkdirAll(vaultDir, 0750); err != nil {
log.Fatalf("Failed to create vault directory: %v", err)
}
}
8. Implementing the Note Editor (Text Area) and Save Workflow (Ctrl+S)
Once a user enters a filename and presses Enter, we create the file on disk, open a full-screen textarea component, and load the file descriptor into our state.
Step 1: Update Model for Text Area and File Tracking
import (
"github.com/charmbracelet/bubbles/textarea"
)
type model struct {
newFileInput textinput.Model
createFileVisible bool
noteTextArea textarea.Model
currentFile *os.File // Tracks the currently open file descriptor
}
Step 2: Handle File Creation and Editor Initialization
When the user presses Enter inside the file input box, we create the physical Markdown file and initialize the text area.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
if m.createFileVisible {
fileName := m.newFileInput.Value()
if fileName != "" {
filePath := filepath.Join(vaultDir, fileName+".md")
// Check if file already exists
if _, err := os.Stat(filePath); err == nil {
return m, nil // File exists, ignore or handle error
}
// Create the file
file, err := os.Create(filePath)
if err != nil {
log.Fatalf("Failed to create file: %v", err)
}
m.currentFile = file
m.createFileVisible = false
m.newFileInput.Reset()
// Initialize Text Area
ta := textarea.New()
ta.Placeholder = "Start writing your markdown note here..."
ta.Focus()
ta.ShowLineNumbers = false
m.noteTextArea = ta
}
}
}
}
// Forward updates to the active component
if m.currentFile != nil {
m.noteTextArea, cmd = m.noteTextArea.Update(msg)
} else if m.createFileVisible {
m.newFileInput, cmd = m.newFileInput.Update(msg)
}
return m, cmd
}
Step 3: Implement the Save Workflow (Ctrl+S)
To save the note, we truncate the file, seek back to the beginning (0, 0), write the current text area content, and close the file descriptor.
case "ctrl+s":
if m.currentFile != nil {
// Truncate the file to clear old content
if err := m.currentFile.Truncate(0); err != nil {
return m, nil
}
// Seek to the beginning of the file
if _, err := m.currentFile.Seek(0, 0); err != nil {
return m, nil
}
// Write the text area content
if _, err := m.currentFile.WriteString(m.noteTextArea.Value()); err != nil {
return m, nil
}
// Close the file descriptor
_ = m.currentFile.Close()
m.currentFile = nil
m.noteTextArea.Reset()
}
return m, nil
9. Building a Paginated, Filterable Note List (Ctrl+L)
To view and search saved notes, we use the list component from Bubbles. This component automatically handles pagination, keyboard navigation, and fuzzy filtering.
Step 1: Define the List Item Struct
The list component requires items to implement the list.Item interface.
import "github.com/charmbracelet/bubbles/list"
type item struct {
title, desc string
}
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }
Step 2: Read Files from the Vault Directory
We read the ~/.tosion/ directory to construct a list of items dynamically.
func listFiles() []list.Item {
entries, err := os.ReadDir(vaultDir)
if err != nil {
return nil
}
var items []list.Item
for _, entry := range entries {
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".md" {
info, err := entry.Info()
var modTime string
if err == nil {
modTime = info.ModTime().Format("2006-01-02 15:04:05")
}
items = append(items, item{
title: entry.Name(),
desc: "Last Modified: " + modTime,
})
}
}
return items
}
Step 3: Integrate the List into the Model
We add the list component and a visibility flag to the model.
type model struct {
// ... other fields
noteList list.Model
showList bool
}
func initialModel() model {
// ... other initializations
items := listFiles()
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "All Notes"
return model{
noteList: l,
showList: false,
// ...
}
}
10. Opening and Editing Existing Notes
When a user selects a note from the list and presses Enter, we must read the file's contents, load it into the text area, and open the editor screen.
case "enter":
if m.showList {
selected, ok := m.noteList.SelectedItem().(item)
if ok {
filePath := filepath.Join(vaultDir, selected.title)
// Read file content
content, err := os.ReadFile(filePath)
if err != nil {
return m, nil
}
// Open file in read-write mode
file, err := os.OpenFile(filePath, os.O_RDWR, 0644)
if err != nil {
return m, nil
}
m.currentFile = file
// Load content into text area
ta := textarea.New()
ta.SetValue(string(content))
ta.Focus()
ta.ShowLineNumbers = false
m.noteTextArea = ta
m.showList = false
}
}
11. Edge Cases, Navigation, and Bug Fixes
Bug 1: Arrow Keys and Enter Key Collisions
When the list is visible, pressing Enter should open a note, but when the file input is visible, pressing Enter should create a file. We must isolate keypress events using conditional checks inside the Update function.
case "enter":
if m.createFileVisible {
// Handle file creation
} else if m.showList {
// Handle opening note
}
return m, nil
Bug 2: List State Desynchronization
If a user creates a new note, the list component does not automatically know about it because the list was initialized at application startup. We must refresh the list items whenever the user opens the list view (Ctrl+L).
case "ctrl+l":
m.showList = true
freshItems := listFiles()
m.noteList.SetItems(freshItems)
return m, nil
Bug 3: Text Area Enter Key Interception
When typing inside the text area, pressing Enter should insert a newline character instead of triggering file creation or list actions. We must intercept the enter key and prevent it from bubbling up if the text area is active.
case "enter":
if m.currentFile != nil {
// Let the text area handle the enter key internally to insert a newline
break
}
12. Challenge: Implementing Note Deletion (Ctrl+D)
To make Tosion fully functional, you can implement a note deletion feature.
The Objective
When navigating the note list (Ctrl+L), pressing Ctrl+D should delete the currently selected Markdown file from the disk and update the list UI in real-time.
Step-by-Step Implementation Guide
- Listen for
ctrl+d: Add a case inside theUpdatemethod under the keypress switch block. - Verify Context: Ensure
m.showLististrueand the user is not currently filtering the list. - Get Selected Item: Retrieve the selected item using
m.noteList.SelectedItem(). - Delete File: Use Go's standard library
os.Remove(filePath)to delete the file from disk. - Refresh UI: Remove the item from the list model using
m.noteList.RemoveItem(index)or reload the list usinglistFiles().
Tabular Synthesis: Bubble Tea Components Comparison
Summary and Key Takeaways
- Terminal User Interfaces (TUIs) provide an interactive, graphical-like experience inside command-line environments by running a continuous input-processing loop.
- The Elm Architecture (Model-View-Update) enforces a clean, unidirectional data flow where state changes are predictable and isolated.
- The Model holds the application state, the Update function handles events (messages) and modifies state, and the View renders the state as a plain string.
- Lip Gloss provides a declarative styling API to apply colors, borders, and padding to terminal text.
- Charm Bubbles provides pre-built interactive components like
textinput,textarea, andlistthat handle complex terminal interactions out of the box. - File system operations in Go are managed using the
osandpath/filepathpackages, allowing you to create, read, write, and delete files safely. - To overwrite file content cleanly, you must truncate the file to zero bytes and seek back to the beginning (
0, 0) before writing new data.