Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/architecture/system-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod
| Live Updates | Server-Sent Events (SSE) |
| Chat RPC | JSONL over stdin/stdout via `pi --mode rpc` |
| Session Storage | JSONL files on disk; pi-web creates new session files and appends `session_info` for browser rename |
| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads and project visibility prefs |
| Local DB | SQLite (`~/.pi/agent/pi-web.sqlite`) for per-project scratchpads, project visibility prefs, and server-backed user settings |
| Auth | Token cookie/query/header (optional on localhost) |

## Component Diagram
Expand Down Expand Up @@ -44,6 +44,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod
│ │
│ GET / → handleIndex (Vite index bundle) │
│ GET /session → handleSession (Vite session bundle shell) │
│ GET /settings → handleSettingsPage (Vite settings bundle shell) │
│ GET /api/session → handleApiSession (JSON) │
│ GET /api/sessions → handleApiSessions (JSON list) │
│ POST /api/chat → handleChat (multipart or JSON) │
Expand All @@ -56,6 +57,7 @@ pi-web is a local HTTP server that lets you browse and interact with your pi cod
│ GET /api/worker-status → handleWorkerStatus │
│ GET /api/git/info / POST /api/git/rename-branch │
│ GET/POST /api/scratchpad → scratchpad (SQLite) │
│ GET/POST /api/settings → user settings (SQLite, write-through cache) │
│ GET/POST /api/projects → project visibility prefs (SQLite) │
│ GET /api/sounds / GET /sounds/… (notification sounds) │
│ POST /share → handleShare (GitHub Gist) │
Expand Down Expand Up @@ -128,7 +130,7 @@ name, while pi-web itself continues listening only on localhost.
├── session-status/
│ ├── 2026-01-15T10-30-00.000Z_a1b2c3d4.jsonl ← terminal writes here
│ └── …
├── pi-web.sqlite ← scratchpads + project visibility prefs
├── pi-web.sqlite ← scratchpads + project visibility prefs + user settings
└── pi-web/
├── pi-web-state.json ← server state file
├── custom-themes.css ← optional user custom theme
Expand Down
8 changes: 7 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func Main(version string) {
RenderIndex: ui.RenderIndex,
RenderLiveSession: ui.RenderLiveSessionPage,
RenderExportSession: ui.RenderExportSessionPage,
RenderSettings: ui.RenderSettings,
Models: func(ctx context.Context) (json.RawMessage, error) {
return defaultModelsCache.get(ctx)
},
Expand All @@ -94,17 +95,22 @@ func Main(version string) {
RunRestart: runRestart,
})

ui.SetThemeProvider(srv.ThemeSetting)
ui.SetFontProvider(srv.FontStyles)

mux := http.NewServeMux()
srv.Register(mux)
ui.RegisterPWAHandlers(mux)
dfs := web.DistFS()
if scripts, err := frontend.LoadScripts(dfs, frontend.IndexEntry, frontend.SessionEntry, frontend.LiveEntry); err == nil {
if scripts, err := frontend.LoadScripts(dfs, frontend.IndexEntry, frontend.SessionEntry, frontend.SettingsEntry, frontend.LiveEntry); err == nil {
for _, script := range scripts {
switch script.Entry {
case frontend.IndexEntry:
ui.SetIndexScriptPath(script.Path)
case frontend.SessionEntry:
ui.SetSessionScriptPath(script.Path)
case frontend.SettingsEntry:
ui.SetSettingsScriptPath(script.Path)
}
mux.HandleFunc(script.Path, frontend.ServeJS(script.JS, script.Path != "/static/assets/index.js"))
}
Expand Down
7 changes: 4 additions & 3 deletions internal/frontend/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import (
)

const (
IndexEntry = "src/index/index.js"
SessionEntry = "src/session/session.js"
LiveEntry = "src/live/live.js"
IndexEntry = "src/index/index.js"
SessionEntry = "src/session/session.js"
SettingsEntry = "src/settings/settings.js"
LiveEntry = "src/live/live.js"

// Backward-compatible unexported aliases used by package tests.
indexEntry = IndexEntry
Expand Down
45 changes: 31 additions & 14 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Deps struct {
RenderIndex func(w io.Writer, summaries []sessions.SessionSummary) error
RenderLiveSession func(s sessions.Session) string
RenderExportSession func(s sessions.Session, theme string) string
RenderSettings func(w io.Writer) error
Models func(ctx context.Context) (json.RawMessage, error)
Now func() time.Time
// Updater reports current/latest version + changelog. Optional; when nil
Expand Down Expand Up @@ -73,6 +74,7 @@ type Server struct {
renderIndex func(w io.Writer, summaries []sessions.SessionSummary) error
renderLiveSession func(s sessions.Session) string
renderExportSession func(s sessions.Session, theme string) string
renderSettings func(w io.Writer) error
models func(ctx context.Context) (json.RawMessage, error)
lastKnown map[string]struct{} // session ids currently broadcast as running
lastKnownMu sync.Mutex
Expand Down Expand Up @@ -117,6 +119,14 @@ func New(deps Deps) *Server {
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create scratchpads table: %v\n", err)
}
_, err = db.Exec(`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at DATETIME
)`)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create settings table: %v\n", err)
}
if _, err := db.Exec(projectPrefsSchema); err != nil {
fmt.Fprintf(os.Stderr, "failed to create project_prefs table: %v\n", err)
}
Expand All @@ -137,6 +147,7 @@ func New(deps Deps) *Server {
renderIndex: deps.RenderIndex,
renderLiveSession: deps.RenderLiveSession,
renderExportSession: deps.RenderExportSession,
renderSettings: deps.RenderSettings,
models: deps.Models,
lastKnown: make(map[string]struct{}),
stopCh: make(chan struct{}),
Expand Down Expand Up @@ -179,6 +190,7 @@ func (s *Server) Shutdown() {
func (s *Server) Register(mux *http.ServeMux) {
mux.HandleFunc("/", s.auth.Wrap(s.handleIndex))
mux.HandleFunc("/session", s.auth.Wrap(s.handleSession))
mux.HandleFunc("/settings", s.auth.Wrap(s.handleSettingsPage))
mux.HandleFunc("/api/session", s.auth.Wrap(s.handleApiSession))
mux.HandleFunc("/api/sessions", s.auth.Wrap(s.handleApiSessions))
mux.HandleFunc("/api/chat", s.auth.Wrap(s.handleChat))
Expand All @@ -194,23 +206,12 @@ func (s *Server) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/clone-session", s.auth.Wrap(s.handleApiCloneSession))
mux.HandleFunc("/api/rename-session", s.auth.Wrap(s.handleRenameSession))
mux.HandleFunc("/api/recent-locations", s.auth.Wrap(s.handleRecentLocations))
mux.HandleFunc("/api/projects", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
s.auth.Wrap(s.handleUpdateProject)(w, r)
} else {
s.auth.Wrap(s.handleApiProjects)(w, r)
}
})
mux.HandleFunc("/api/projects", s.getPostHandler(s.handleApiProjects, s.handleUpdateProject))
mux.HandleFunc("/api/git/info", s.auth.Wrap(s.handleGitInfo))
mux.HandleFunc("/api/git/rename-branch", s.auth.Wrap(s.handleGitRenameBranch))
mux.HandleFunc("/custom-themes.css", s.auth.Wrap(s.handleCustomThemes))
mux.HandleFunc("/api/scratchpad", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
s.auth.Wrap(s.handleSaveScratchpad)(w, r)
} else {
s.auth.Wrap(s.handleGetScratchpad)(w, r)
}
})
mux.HandleFunc("/api/scratchpad", s.getPostHandler(s.handleGetScratchpad, s.handleSaveScratchpad))
mux.HandleFunc("/api/settings", s.getPostHandler(s.handleGetSettings, s.handleSaveSettings))
mux.HandleFunc("/api/btw", s.auth.Wrap(s.handleGetBtw))
mux.HandleFunc("/api/btw/new", s.auth.Wrap(s.handleNewBtw))
if s.push != nil {
Expand All @@ -226,6 +227,22 @@ func (s *Server) Register(mux *http.ServeMux) {
}
}

// getPostHandler routes GET to get and POST to post, each wrapped with auth,
// and rejects any other method with 405 (and an Allow header) before auth runs.
func (s *Server) getPostHandler(get, post http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.auth.Wrap(get)(w, r)
case http.MethodPost:
s.auth.Wrap(post)(w, r)
default:
w.Header().Set("Allow", "GET, POST")
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
}

func (s *Server) loadSummaries() ([]sessions.SessionSummary, error) {
return s.cache.LoadAll(s.sessionsDir)
}
Expand Down
217 changes: 217 additions & 0 deletions internal/server/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package server

import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
)

// handleSettingsPage renders the global /settings page.
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
if s.renderSettings == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.renderSettings(w); err != nil {
if !isBrokenPipe(err) {
fmt.Fprintf(os.Stderr, "settings template error: %v\n", err)
}
}
}

// settingDefaults defines the server-backed user settings and their default
// values. The keys mirror the localStorage keys the frontend already uses so
// the write-through cache maps 1:1. Only keys listed here are accepted by
// POST /api/settings; everything else is ignored. Genuinely per-window or
// live-timer state (sidebar widths, focus countdown, tree toggles) is NOT
// listed here — it stays in localStorage only.
var settingDefaults = map[string]string{
"pi-web-theme": "dark",
"pi-web:v1:font-ui": "mono",
"pi-web:v1:font-content": "mono",
"pi-web:v1:font-ui-size": "12",
"pi-web:v1:font-content-size": "13",
"pi-sessions:spinner-style": "runcat",
"pi-share:v1:notify-on-done": "false",
"pi-share:v1:done-sound": "cat.mp3",
"pi-sessions:view-layout": "timeline",
"pi-web:v1:cat:enabled": "true",
"pi-web:v1:cat:focus-min": "25",
"pi-web:v1:cat:break-min": "5",
"pi-web:v1:cat:bedtime": "23:00",
"pi-web:v1:cat:wakeup": "07:00",
"pi-web:v1:cat:sleep-min": "2",
}

// getSettings returns every server-backed setting: defaults overlaid with any
// values stored in the DB. Degrades gracefully to defaults when there is no DB.
func (s *Server) getSettings() map[string]string {
out := make(map[string]string, len(settingDefaults))
for k, v := range settingDefaults {
out[k] = v
}
if s.db == nil {
return out
}
rows, err := s.db.Query("SELECT key, value FROM settings")
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
continue
}
// Only surface keys we still recognize.
if _, ok := settingDefaults[key]; ok {
out[key] = value
}
}
return out
}

// getSetting returns a single server-backed setting, falling back to its
// default (or the supplied fallback for unknown keys).
func (s *Server) getSetting(key, fallback string) string {
if def, ok := settingDefaults[key]; ok {
fallback = def
}
if s.db == nil {
return fallback
}
var value string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
if err != nil {
return fallback
}
return value
}

// ThemeSetting returns the persisted theme, used for server-side injection so
// the page paints the correct theme before any JS runs.
func (s *Server) ThemeSetting() string {
return s.getSetting("pi-web-theme", "dark")
}

// fontKeywords maps a curated font name to a full CSS font-family stack. The
// stored font value is either one of these keywords or a raw family name the
// user typed / picked from their installed fonts. Kept in sync with
// web/src/shared/fonts.js.
var fontKeywords = map[string]string{
"mono": "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
"system": "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
"sans": "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
"serif": "Georgia, 'Times New Roman', Times, serif",
}

// sanitizeFontFamily strips a raw family name down to a safe subset so it can be
// injected into a CSS <style> block without breaking out of the value context.
func sanitizeFontFamily(value string) string {
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == ' ', r == '-':
b.WriteRune(r)
}
if b.Len() >= 64 {
break
}
}
return strings.TrimSpace(b.String())
}

// resolveFontStack turns a stored font value (a keyword or a raw family name)
// into a CSS font-family stack. Raw families are sanitized, quoted, and given
// the monospace stack as a fallback so text always renders.
func resolveFontStack(value string) string {
if stack, ok := fontKeywords[value]; ok {
return stack
}
family := sanitizeFontFamily(value)
if family == "" {
return fontKeywords["mono"]
}
return "'" + family + "', " + fontKeywords["mono"]
}

func sanitizeFontSize(value, fallback string) string {
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
n, _ = strconv.Atoi(fallback)
}
if n < 8 {
n = 8
}
if n > 32 {
n = 32
}
return strconv.Itoa(n)
}

// FontStyles returns the resolved interface/content font stacks and pixel sizes
// for server-side injection so the page paints with the chosen fonts/sizes
// before any JS runs.
func (s *Server) FontStyles() (uiStack, contentStack, uiSize, contentSize string) {
uiStack = resolveFontStack(s.getSetting("pi-web:v1:font-ui", "mono"))
contentStack = resolveFontStack(s.getSetting("pi-web:v1:font-content", "mono"))
uiSize = sanitizeFontSize(s.getSetting("pi-web:v1:font-ui-size", "12"), "12")
contentSize = sanitizeFontSize(s.getSetting("pi-web:v1:font-content-size", "13"), "13")
return
}

func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"settings": s.getSettings()})
}

func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}

var body struct {
Settings map[string]string `json:"settings"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json body")
return
}
if len(body.Settings) == 0 {
writeJSONError(w, http.StatusBadRequest, "settings is required")
return
}

// Without a DB, writes are a no-op but still report success so the
// write-through cache (localStorage) keeps working read-only.
if s.db == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "settings": s.getSettings()})
return
}

now := time.Now()
for key, value := range body.Settings {
if _, ok := settingDefaults[key]; !ok {
continue // ignore unknown keys
}
_, err := s.db.Exec(`INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
key, value, now)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to save settings: "+err.Error())
return
}
}

writeJSON(w, http.StatusOK, map[string]any{"ok": true, "settings": s.getSettings()})
}
Loading
Loading