CLAUDE.md
March 3, 2026 · View on GitHub
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
SuperChat is a terminal-based threaded chat application with a custom binary protocol. The V1 implementation focuses on anonymous users, TCP connections, and forum-style threading. The codebase is designed for forward compatibility with V2 features (user registration, SSH, DMs, encryption) through extensible protocol design and forward-compatible database schemas.
Current Status: V2 Complete! 🎉
- See
docs/versions/V2.mdfor V2 implementation summary - See
docs/versions/V1.mdfor V1 specification and rationale - See
docs/versions/V3.mdfor planned V3 features (not started)
⚠️ CRITICAL: Protocol Changes ⚠️
NEVER modify the binary protocol without updating documentation first!
Before making ANY changes to the protocol (adding fields, changing message types, modifying encoding):
-
FIRST: Update
docs/PROTOCOL.mdwith the complete specification- CHECK FOR DUPLICATES! Search PROTOCOL.md for your desired message type code (e.g.,
0x1C) to ensure it's not already in use - Document the exact wire format
- Specify field types, sizes, and encoding
- Include examples of the binary layout
- Note version compatibility implications
- CHECK FOR DUPLICATES! Search PROTOCOL.md for your desired message type code (e.g.,
-
THEN: Implement the change in code
- Update
pkg/protocol/messages.go - Add comprehensive tests (encoding, decoding, round-trip, error cases)
- Update both client and server handlers
- Update
-
FINALLY: Update this file (CLAUDE.md) if the change affects usage patterns
Rationale: The protocol is the contract between client and server. Undocumented protocol changes lead to:
- Client/server incompatibility
- Impossible debugging (no reference for wire format)
- Breaking changes without warning
- Lost knowledge of encoding decisions
If you're about to modify protocol code, STOP and update PROTOCOL.md first!
UI Terminology (Important!)
User often confuses these two screens - always clarify which one they mean:
- Splash Screen (
ViewSplash): First-run only welcome screen shown once. Most users never see this again. - Channel List Welcome Message (
renderChannelList): The actual "welcome" users see every time - shown on the channel list view when no channel is selected. This is what users usually mean when they say "splash screen" or "welcome screen".
When user asks to update the "splash" or "welcome" screen, confirm which one they mean!
Web Client UI Guidelines
No emoji in the web client. Use Phosphor Icons (@phosphor-icons/core) for all icons. Import as inline SVGs via the Icon component (src/components/Icon.tsx) which renders them with fill="currentColor" so they adapt to any theme. Add new icons to the registry in that file.
Always use safeLog instead of console.log in the web client when logging values that may contain BigInts. Mobile browsers (especially Safari) crash on JSON.stringify(BigInt) which console.log triggers internally. Import from lib/utils/safe-log.ts. Template literal interpolation (`value: ${bigint}`) is safe since it calls toString() implicitly.
UI Layout Guidelines
ALWAYS use flexbox layouts from github.com/76creates/stickers/flexbox for any UI layout work.
There is no downside to using flexbox everywhere - it provides:
- Automatic alignment and spacing
- Consistent sizing behavior
- Proper handling of terminal dimensions
- Clean, maintainable code
Example: Modal with flexbox layout
import "github.com/76creates/stickers/flexbox"
func (m *MyModal) Render(width, height int) string {
// Create vertical layout
layout := flexbox.New(width, height)
// Row 1: Title (fixed height)
titleRow := layout.NewRow().AddCells(
flexbox.NewCell(1, 1).SetContent(
lipgloss.NewStyle().Bold(true).Render("My Modal Title"),
),
)
// Row 2: Content area (flexible height)
contentHeight := height - 4 // Subtract title + footer + spacing
contentRow := layout.NewRow().AddCells(
flexbox.NewCell(1, contentHeight).SetContent(buildContent()),
)
// Row 3: Footer (fixed height)
footerRow := layout.NewRow().AddCells(
flexbox.NewCell(1, 1).SetContent(
lipgloss.NewStyle().Foreground(lipgloss.Color("240")).
Render("[Enter] Confirm [Esc] Cancel"),
),
)
layout.AddRows([]*flexbox.Row{titleRow, contentRow, footerRow})
return layout.Render()
}
Horizontal layouts (multi-column)
// Create horizontal layout for side-by-side content
contentLayout := flexbox.NewHorizontal(width, height)
// Column 1: Sidebar (ratio 1 = 25%)
sidebarCol := contentLayout.NewColumn().AddCells(
flexbox.NewCell(1, 1).SetContent(sidebarContent),
)
// Column 2: Main content (ratio 3 = 75%)
mainCol := contentLayout.NewColumn().AddCells(
flexbox.NewCell(1, 1).SetContent(mainContent),
)
contentLayout.AddColumns([]*flexbox.Column{sidebarCol, mainCol})
return contentLayout.Render()
See pkg/client/ui/view/channel_list.go for a complete real-world example.
Build Commands
IMPORTANT: Always use make build to build both client and server together.
# Build both server and client (PREFERRED)
make build
# Build server only (if needed)
go build -o superchat-server ./cmd/server
# Build client only (if needed)
go build -o superchat ./cmd/client
# Run server
go run ./cmd/server
# Run client
go run ./cmd/client
Note: When implementing features that touch both client and server, always use make build to ensure both compile successfully.
Logging
Server logs:
server.log- All server activity (connections, messages, errors). Truncated on each server startup. Located in~/.local/share/superchat/or$XDG_DATA_HOME/superchat/.errors.log- Error-level logs only (append mode, persists across restarts). Located in~/.local/share/superchat/or$XDG_DATA_HOME/superchat/.debug.log- Server debug logs (when enabled). Located in~/.local/share/superchat/or$XDG_DATA_HOME/superchat/.
Load test logs:
loadtest.log- All load test output (same as console output). Truncated on each test run. Written to the current working directory.
Client logs:
debug.log- Client debug logs (located in~/.config/superchat-client/or$XDG_CONFIG_HOME/superchat-client/). Append mode, persists across restarts.
Notes:
- Server log files are written to the data directory (
~/.local/share/superchat/) and are git-ignored. - Loadtest log files are written to the current working directory and are git-ignored.
- Client debug.log is written to the config directory and is also git-ignored.
Testing
# Run all tests with race detector
make test
# Generate coverage for all packages (combined report)
make coverage
# Generate HTML coverage report
make coverage-html
# Generate separate LCOV files per package (protocol, server, client)
make coverage-lcov
# Check protocol package has at least 85% coverage (enforced)
make coverage-protocol
# View coverage summary
make coverage-summary
# Run fuzzing tests
make fuzz
# Clean coverage artifacts
make clean
# Run load/performance tests
go build -o loadtest ./cmd/loadtest
./loadtest --server localhost:6465 --clients 200 --duration 10s --min-delay 100ms --max-delay 500ms
Load Testing
The loadtest tool simulates concurrent clients to stress test the server:
Available flags:
--server- Server address (default: "localhost:6465")--clients- Number of concurrent clients (default: 10)--duration- Test duration (default: 1m)--min-delay- Minimum delay between posts (default: 100ms)--max-delay- Maximum delay between posts (default: 1s)
Example commands:
# Light load: 50 clients for 10 seconds
./loadtest --server localhost:6465 --clients 50 --duration 10s
# Heavy load: 200 clients for 1 minute
./loadtest --server localhost:6465 --clients 200 --duration 1m
# Stress test: 500 clients with aggressive posting
./loadtest --server localhost:6465 --clients 500 --duration 30s --min-delay 50ms --max-delay 200ms
How it works:
- Each client connects with a randomly generated username (e.g., "partmir", "mostra")
- Picks a random channel to join
- 10% chance to create a new thread, 90% chance to reply to existing message
- Posts messages with random delays between min-delay and max-delay
- Refreshes message list every 10 posts to discover new threads
Metrics reported:
- Messages posted and throughput (msg/s)
- Success rate (%)
- Average response time (ms)
- Efficiency (actual vs expected throughput)
- Failure breakdown (post failures, fetch failures, timeouts, connection errors)
Performance Profiling
Current observations:
- Local machine maxes out at ~10k concurrent connections (CPU-bound)
- Average response time: 75ms (after switching to in-memory writes with background flush)
- CPU is the limiting factor, not kernel limits
To profile:
# Add to server main.go:
import _ "net/http/pprof"
import "net/http"
# Start pprof server:
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
# During load test, capture 30s CPU profile:
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Check for:
# - Broadcasting overhead (N writes per message)
# - Lock contention (RWMutex with 10k goroutines)
# - Allocation pressure / GC
# - Protocol encoding cost
Coverage Requirements
- Protocol package (
pkg/protocol/*): 85% coverage required - build fails if not met - Server package (
pkg/server/*): 80-90% target - Client package (
pkg/client/*): 70-80% target
The protocol package has strict 85% coverage requirement because it's the foundation of client-server communication and must be thoroughly tested. The remaining ~15% is primarily trivial error-forwarding code in wrapper methods (e.g., Encode() wrappers that call EncodeTo()) which would require extensive busywork to test without adding meaningful value.
Architecture
Three-Layer Structure
-
Protocol Layer (
pkg/protocol/): Binary protocol encoding/decoding- Frame-based wire format with length prefix
- Message type definitions (0x01-0x98)
- All encoding uses
EncodeTo(io.Writer)pattern for efficiency - Zero message types between client→server and server→client
-
Server Layer (
pkg/server/): TCP server and message handlers- Session management (anonymous and registered users)
- Database operations (SQLite)
- Real-time message broadcasting to connected clients
- Rate limiting and timeout enforcement
-
Client Layer (
pkg/client/): TUI client with bubbletea- Connection management with auto-reconnect
- Local state persistence (SQLite in
~/.config/superchat-client/) - Keyboard-driven navigation with vim-like keys
- Multiple views: channel list, thread list, thread view, compose
Protocol Design
Wire Format:
[Length (4B)][Version (1B)][Type (1B)][Flags (1B)][Payload (N bytes)]
- Length excludes itself, includes Version+Type+Flags+Payload
- Protocol version is always validated on both encode and decode
- Max frame size: 1MB
- Flags: bit 0 = compression, bit 1 = encryption
Key Protocol Principles:
- V1 subset: Not all message types are implemented in V1. Unimplemented messages return ERROR 1001.
- Forward compatibility: V1 clients ignore unknown server message types (for V2 compatibility)
- Encoding pattern: All message types use
EncodeTo(io.Writer)andDecode([]byte)methods - Optional fields: Use
(bool present, value)encoding (wastes 7 bits but much simpler)
See docs/PROTOCOL.md for full specification.
Database Schema
V1 Tables (anonymous-only):
Channel: Top-level containers (admin-created only in V1)Session: Active TCP connections (no persistent users)Message: All messages with threading (parent_id, thread_depth)MessageVersion: Edit/delete history for moderation
Key Schema Decisions:
Message.author_user_idis NULL for V1 (anonymous), populated in V2 (registered)Message.thread_depthis denormalized (0-5+) for fast display- Soft-delete via
deleted_at, hard-delete via retention policy - All foreign keys have explicit CASCADE/SET NULL behavior
See docs/DATA_MODEL.md for full schema.
Database Migrations
SuperChat uses an automatic migration system for schema evolution:
- Automatic execution: Runs on server startup before loading MemDB
- Automatic backup: Creates timestamped backup before applying any migrations
- Version tracking:
schema_migrationstable tracks applied migrations - Embedded files: Migration SQL files embedded in binary via Go embed
Migration files: pkg/database/migrations/<version>_<name>.sql
Creating a new migration:
- Determine next version:
sqlite3 db.db "SELECT MAX(version) FROM schema_migrations" - Create file:
pkg/database/migrations/002_add_feature.sql - Write SQL (use
IF NOT EXISTSfor safety) - Test with isolated database
- Commit
Backup format: superchat.db.backup-v<version>-<timestamp>
See docs/MIGRATIONS.md for complete guide.
Client UI Architecture
Bubbletea Model-View-Update:
Modelholds all application state (connection, server data, UI state, input state)Update()handles all events (keyboard, server messages, timer ticks)View()renders current view based oncurrentViewstate
View States:
ViewSplash: First-run welcome screenViewNicknameSetup: Nickname input modalViewChannelList: Channel sidebar + instructionsViewThreadList: Channel sidebar + thread list (root messages)ViewThreadView: Single thread with nested repliesViewCompose: Message composition modal
Lipgloss Width Handling:
.Width(x)sets content width, borders are added on top- A pane with
.Width(50).Border(RoundedBorder())renders at 52 characters total - Always subtract border width (2) from desired total width when using borders
⚠️ CRITICAL: BubbleTea Command Closures and Model State
BubbleTea's Update function works asynchronously:
Update()returns(Model, Cmd)- The returned
Modelbecomes the new application state - Commands execute with a copy of the model at the time they were created
The Problem:
When you create a command closure (like model.sendLeaveChannel()), it captures the model at that moment. If that model's state gets updated by a later message handler (like handleJoinResponse setting activeChannelID), the command closure still has the old model state.
Example Bug Pattern:
// User presses Escape in chat view
func escapeHandler(model *Model) tea.Cmd {
// Command created here with current model state
return model.sendLeaveChannel() // ❌ Uses model.activeChannelID (might be 0!)
}
func (m Model) sendLeaveChannel() tea.Cmd {
return func() tea.Msg {
// This closure captured m.activeChannelID when command was created
// Even if handleJoinResponse later sets activeChannelID=20,
// this closure still sees the old value (0)
if m.activeChannelID == 0 {
return nil // ❌ Returns early with stale state!
}
// ... send leave message ...
}
}
The Fix: Pass explicit parameters to command functions instead of relying on captured model state:
// ✅ GOOD: Pass channel ID explicitly
func escapeHandler(model *Model) tea.Cmd {
if model.currentChannel != nil {
return model.sendLeaveChannel(model.currentChannel.ID)
}
return nil
}
func (m Model) sendLeaveChannel(channelID uint64) tea.Cmd {
return func() tea.Msg {
// Uses explicit parameter, not stale model state
if channelID == 0 {
return nil
}
// ... send leave message with channelID ...
}
}
Key Takeaway:
- Model fields that get updated by server responses (
activeChannelID, etc.) are unreliable in command closures - Always pass explicit parameters for critical data instead of relying on captured model state
- Use synchronously-set fields (
currentChannel, etc.) that were set in the same Update cycle, not async server responses
Real-Time Updates
Server broadcasts these message types to relevant clients:
NEW_MESSAGE: Sent to all users in a channel when someone postsCHANNEL_CREATED: Sent to all users when admin creates channelSERVER_STATS: Periodic broadcast (every 30s) with online user count
Client buffers broadcasts during message composition to avoid disrupting the user.
Common Patterns
Adding a New Message Type
- Define message type constant in
pkg/protocol/messages.go - Add struct with
EncodeTo(io.Writer)andDecode([]byte)methods - Write comprehensive tests (table-driven, round-trip, error cases)
- Add handler in
pkg/server/handlers.go(or stub with ERROR 1001 for V2) - Add client handling in
pkg/client/ui/update.go
Protocol Encoding Pattern
All message encoding follows this pattern:
func (m *MessageType) EncodeTo(w io.Writer) error {
if err := WriteUint64(w, m.Field1); err != nil {
return err
}
if err := WriteString(w, m.Field2); err != nil {
return err
}
return WriteOptionalUint64(w, m.OptionalField)
}
Never allocate intermediate buffers - write directly to io.Writer for efficiency.
Adding a Database Migration
CRITICAL: Migrations must include tests validating data integrity!
- Determine next version:
sqlite3 db.db "SELECT MAX(version) FROM schema_migrations" - Create migration file:
pkg/database/migrations/00X_description.sql- Use
IF NOT EXISTSfor safety - Include indexes in same migration
- Add SQL comments for complex transformations
- Use
- Update
migration_path_test.go(REQUIRED):- Add test case in
TestMigrationPath - Create sample data in old schema (
setupData) - Validate schema changes (
validateSchema) - Validate data integrity (
validateData) - Update
TestFullMigrationPathif data format changes
- Add test case in
- Run tests:
go test ./pkg/database -run TestMigrationPath -v - Test with real database:
./superchat-server --db /tmp/test.db - Commit together: Migration SQL + test updates (never separate!)
Example v1→v2 migration:
-- 002_add_user_table.sql
CREATE TABLE IF NOT EXISTS User (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nickname TEXT UNIQUE NOT NULL,
registered INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Existing messages keep NULL author_user_id (anonymous)
-- No data transformation needed
See docs/MIGRATIONS.md for complete guide.
Database Transactions
Always wrap multi-table operations in transactions:
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback() // Safe to call even after Commit
// ... multiple operations ...
if err := tx.Commit(); err != nil {
return err
}
Critical for: user registration, channel creation, message posting with versions.
Version Status
V1: Complete ✅
- Anonymous users, TCP connections, forum threading, client-side state
V2: Complete ✅
- ✅ User registration with passwords
- ✅ User-created channels
- ✅ Message editing (EDIT_MESSAGE, MESSAGE_EDITED)
- ✅ SSH key authentication with auto-registration
- ✅ Chat channel type (type 0 for linear chat, type 1 for threaded forum)
See docs/versions/V2.md for complete V2 feature documentation.
V3 (Future):
- Subchannels (two-level hierarchy)
- Direct messages with encryption
- Message compression
Compatibility Strategy:
- Protocol messages 0x01-0x98 are defined but many return ERROR 1001 until implemented
- Database has nullable fields for V2 features (
author_user_id,subchannel_id) - V1 leaves these NULL, V2 populates them
- No schema migration needed for V1→V2 upgrade (additive only)
Key Files
Primary Documentation:
docs/PROTOCOL.md: Complete binary protocol specificationdocs/DATA_MODEL.md: Database schema and relationshipsdocs/MIGRATIONS.md: Database migration system guidedocs/IMPROVEMENTS_ROADMAP.md: Pre-release checklist and UX improvements
Version Documentation:
docs/versions/V1.md: V1 feature scope and implementation (COMPLETE)docs/versions/V2.md: V2 feature summary (COMPLETE) ⭐docs/versions/V3.md: V3 planned features (NOT STARTED)
Historical Feature Design Documentation:
docs/feature_design/: Architectural decision records and implementation plans for major featuressubscription_system.md: Design and implementation of the message subscription/broadcast optimization (COMPLETED)ssh_authentication.md: Complete SSH key authentication implementation plan (COMPLETED)loadtest_race_condition_debug.md: Historical debugging session that fixed TCP write race condition
Core Implementation Files:
pkg/protocol/frame.go: Frame encoding/decodingpkg/protocol/messages.go: All message type definitionspkg/server/handlers.go: Server message handlerspkg/database/migrations.go: Migration system implementationpkg/database/migrations/*.sql: Schema migration filespkg/client/ui/model.go: Client state modelpkg/client/ui/update.go: Event handling and server message processingpkg/client/ui/view.go: TUI rendering
Important Constraints
- Max frame size: 1MB (prevents DoS)
- Session timeout: 60 seconds of inactivity (client sends PING every 30s)
- Message retention: 7 days default (configurable per channel)
- Rate limiting: 10 messages/minute per session (V1 default)
Testing Philosophy
- Protocol package: 90% coverage required minimum
- Use table-driven tests for all message encode/decode
- Test both success and error paths (especially io.Writer failures)
- Property-based tests (rapid) for serialization round-trips
- Fuzzing for malformed input handling
- Integration tests for multi-client real-time broadcasts
- Anything less than a 100% success rate, is NOT production-ready performance. Don't suggest that we should accept failures just because we're operating under heavy load. Under heavy load we should gracefully degrade, NOT fail.