From f922b855eb07624f72dd3b2af4270173fcd72335 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 30 May 2026 00:00:35 +0400 Subject: [PATCH] feat: add Go/Bubble Tea TUI rewrite (apps/tui-go) Reimplement the TUI in Go using Bubble Tea, replacing the Kotlin/Tamboui app. Same WebSocket protocol, soft-rounded layout with blue accent theme. --- .gitignore | 4 +- apps/tui-go/cmd/preview/main.go | 30 + apps/tui-go/go.mod | 32 ++ apps/tui-go/go.sum | 50 ++ apps/tui-go/internal/app/demo.go | 144 +++++ apps/tui-go/internal/app/model.go | 242 ++++++++ apps/tui-go/internal/app/overlays.go | 286 ++++++++++ apps/tui-go/internal/app/server.go | 331 +++++++++++ apps/tui-go/internal/app/theme.go | 142 +++++ apps/tui-go/internal/app/update.go | 665 ++++++++++++++++++++++ apps/tui-go/internal/app/view.go | 548 ++++++++++++++++++ apps/tui-go/internal/protocol/protocol.go | 211 +++++++ apps/tui-go/internal/ws/client.go | 163 ++++++ apps/tui-go/main.go | 31 + 14 files changed, 2878 insertions(+), 1 deletion(-) create mode 100644 apps/tui-go/cmd/preview/main.go create mode 100644 apps/tui-go/go.mod create mode 100644 apps/tui-go/go.sum create mode 100644 apps/tui-go/internal/app/demo.go create mode 100644 apps/tui-go/internal/app/model.go create mode 100644 apps/tui-go/internal/app/overlays.go create mode 100644 apps/tui-go/internal/app/server.go create mode 100644 apps/tui-go/internal/app/theme.go create mode 100644 apps/tui-go/internal/app/update.go create mode 100644 apps/tui-go/internal/app/view.go create mode 100644 apps/tui-go/internal/protocol/protocol.go create mode 100644 apps/tui-go/internal/ws/client.go create mode 100644 apps/tui-go/main.go diff --git a/.gitignore b/.gitignore index b64db83a..2fc5e291 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,6 @@ bin/ log4j2.properties /docs/specs/ correx-tui.log -current.log \ No newline at end of file +current.log +# Go TUI build output +apps/tui-go/bin/ diff --git a/apps/tui-go/cmd/preview/main.go b/apps/tui-go/cmd/preview/main.go new file mode 100644 index 00000000..1dee08c9 --- /dev/null +++ b/apps/tui-go/cmd/preview/main.go @@ -0,0 +1,30 @@ +// Command preview prints a single static TUI frame for screenshotting. +// Usage: preview [cols] [rows] +package main + +import ( + "fmt" + "os" + "strconv" + + "github.com/charmbracelet/lipgloss" + "github.com/correx/tui-go/internal/app" + "github.com/muesli/termenv" +) + +func main() { + lipgloss.SetColorProfile(termenv.TrueColor) + + kind := "idle" + if len(os.Args) > 1 { + kind = os.Args[1] + } + cols, rows := 140, 36 + if len(os.Args) > 2 { + cols, _ = strconv.Atoi(os.Args[2]) + } + if len(os.Args) > 3 { + rows, _ = strconv.Atoi(os.Args[3]) + } + fmt.Print(app.PreviewFrame(kind, cols, rows)) +} diff --git a/apps/tui-go/go.mod b/apps/tui-go/go.mod new file mode 100644 index 00000000..d1dee92b --- /dev/null +++ b/apps/tui-go/go.mod @@ -0,0 +1,32 @@ +module github.com/correx/tui-go + +go 1.26 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.6 + github.com/gorilla/websocket v1.5.3 + github.com/muesli/termenv v0.16.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/apps/tui-go/go.sum b/apps/tui-go/go.sum new file mode 100644 index 00000000..19aef85f --- /dev/null +++ b/apps/tui-go/go.sum @@ -0,0 +1,50 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go new file mode 100644 index 00000000..07a591d1 --- /dev/null +++ b/apps/tui-go/internal/app/demo.go @@ -0,0 +1,144 @@ +package app + +// PreviewFrame renders a single static frame for a named UI state at the given +// terminal size. Used by cmd/preview to screenshot the look without a live +// server. Not part of the runtime path. +func PreviewFrame(kind string, w, h int) string { + m := NewModel(nil) + m.width, m.height = w, h + m.theme = NewTheme(SoftBlue) + + switch kind { + case "idle-empty": + m.connected = false + + case "idle": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.workflows = sampleWorkflows() + m.bgUpdates = 3 + m.selectedID = "04a546aa" + + case "workflows": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.workflows = sampleWorkflows() + m.selectedID = "04a546aa" + m.wfVisible = true + m.wfIndex = 1 + + case "session", "insert": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.routerConnected = true + m.routerMessages["04a546aa"] = []RouterEntry{ + {"user", "write a healthcheck script for the api"}, + {"router", "I'll create a script that pings /health and checks the status code, then writes the results to a timestamped file."}, + {"tool", "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo \"ok $(date)\"\n"}, + } + if s := m.session("04a546aa"); s != nil { + s.CurrentStage = "write_script" + s.Events = sampleEvents() + s.Active = true + s.ToolsByStage = sampleManifest() + } + if kind == "insert" { + m.editMode = ModeInsert + m.inputBuffer = "now add a retry with backoff" + m.inputCursor = len(m.inputBuffer) + } + + case "tools": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + if s := m.session("04a546aa"); s != nil { + s.CurrentStage = "write_script" + s.ToolsByStage = sampleManifest() + } + m.overlay = OverlayToolPalette + + case "approval": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + if s := m.session("04a546aa"); s != nil { + s.CurrentStage = "write_script" + s.Events = sampleEvents() + s.Pending = &Approval{ + RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "HIGH", + ToolName: "file_write", + Preview: "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+curl -sf http://localhost:8080/health\n+echo ok\n", + } + } + + case "diff": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.sessionEntered = true + m.routerMessages["04a546aa"] = []RouterEntry{ + {"tool", "--- a/healthcheck.sh\n+++ b/healthcheck.sh\n@@ -1,2 +1,6 @@\n #!/usr/bin/env bash\n-echo hi\n+curl -sf http://localhost:8080/health\n+if [ $? -ne 0 ]; then\n+ echo \"unhealthy\" >&2\n+ exit 1\n+fi\n+echo \"ok $(date)\"\n"}, + } + m.overlay = OverlayDiff + + case "palette": + m.connected = true + m.currentModel = "llama-cpp:default" + m.sessions = sampleSessions() + m.selectedID = "04a546aa" + m.overlay = OverlayPalette + m.paletteFilter = "to" + } + return m.View() +} + +func sampleWorkflows() []Workflow { + return []Workflow{ + {ID: "healthcheck", Description: "ping endpoints and report status"}, + {ID: "refactor", Description: "multi-stage code refactor with review"}, + {ID: "triage", Description: "classify and route incoming issues"}, + } +} + +func sampleManifest() map[string][]ManifestTool { + return map[string][]ManifestTool{ + "plan": {{"read_file", 0}, {"list_dir", 0}}, + "write_script": {{"file_write", 3}, {"shell_exec", 4}, {"read_file", 0}}, + "verify": {{"shell_exec", 4}, {"http_get", 1}}, + } +} + +func sampleSessions() []Session { + return []Session{ + {ID: "04a546aa", Status: "FAILED", WorkflowID: "healthcheck", Name: "healthcheck", CurrentStage: "write_script", LastEventAt: 1748000000000}, + {ID: "0d7097bb", Status: "COMPLETED", WorkflowID: "healthcheck", Name: "healthcheck", LastEventAt: 1748000000000}, + {ID: "1dae17cc", Status: "CHAT", WorkflowID: "chat", Name: "chat", LastEventAt: 1748000000000}, + {ID: "338f09dd", Status: "FAILED", WorkflowID: "healthcheck", Name: "healthcheck", CurrentStage: "write_script", LastEventAt: 1748000000000}, + {ID: "3f362dee", Status: "CHAT", WorkflowID: "chat", Name: "chat", LastEventAt: 1748000000000}, + {ID: "92f6c3ff", Status: "CHAT", WorkflowID: "chat", Name: "chat", LastEventAt: 1748000000000}, + {ID: "9a7682gg", Status: "COMPLETED", WorkflowID: "healthcheck", Name: "healthcheck", LastEventAt: 1748000000000}, + } +} + +func sampleEvents() []EventEntry { + return []EventEntry{ + {"13:42:36", "InferenceStarted", "write_script"}, + {"13:43:00", "InferenceCompleted", "write_script"}, + {"13:43:00", "ToolStarted", "file_write"}, + {"13:43:00", "SessionPaused", "APPROVAL_PENDING"}, + {"13:44:10", "ToolCompleted", "file_write"}, + {"13:44:10", "InferenceStarted", "write_script"}, + {"13:44:10", "SessionFailed", "CANCELLED"}, + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go new file mode 100644 index 00000000..c476e72e --- /dev/null +++ b/apps/tui-go/internal/app/model.go @@ -0,0 +1,242 @@ +package app + +import ( + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +// DisplayState is the top-level screen the UI renders. +type DisplayState int + +const ( + StateIdle DisplayState = iota + StateInSession + StateApproval +) + +// InputMode toggles what a submitted line targets. +type InputMode int + +const ( + ModeRouter InputMode = iota // chat / steering input + ModeFilter // session-list filter +) + +// EditMode is the vim-style modality: Normal = bare-key commands, Insert = typing. +type EditMode int + +const ( + ModeNormal EditMode = iota + ModeInsert +) + +// ChatMode selects how a turn is sent to the router. +const ( + ChatModeChat = "CHAT" + ChatModeSteering = "STEERING" +) + +// OverlayKind is the active modal (immediate-mode: drawn on top when set). +type OverlayKind int + +const ( + OverlayNone OverlayKind = iota + OverlayPalette + OverlayEventInspector + OverlayDiff + OverlayToolPalette +) + +// RouterEntry is one line in a session's conversation transcript. +type RouterEntry struct { + Role string // user | router | tool + Content string +} + +// EventEntry is a row in the event stream. +type EventEntry struct { + Time string + Type string + Detail string +} + +// ToolStatus mirrors the Kotlin ToolDisplayStatus. +type ToolStatus int + +const ( + ToolStarted ToolStatus = iota + ToolCompleted + ToolFailed + ToolRejected +) + +type ToolRecord struct { + Name string + Tier int + Status ToolStatus +} + +// ManifestTool is a declared (not-yet-run) tool from a stage manifest. +type ManifestTool struct { + Name string + Tier int +} + +// Approval is a pending approval gate for a session. +type Approval struct { + RequestID string + SessionID string + Tier string + Risk string + ToolName string + Preview string +} + +// Session is the UI's view of one server session. +type Session struct { + ID string + Status string + WorkflowID string + Name string + LastEventAt int64 + CurrentStage string + LastOutput string + LastResponse string + Tools []ToolRecord + ToolsByStage map[string][]ManifestTool + Events []EventEntry + Pending *Approval + Active bool // an inference/tool is in flight (drives the spinner) +} + +// Workflow is a launchable workflow advertised by the server. +type Workflow struct { + ID string + Description string +} + +// Model is the whole TUI state. View is a pure function of it (immediate-mode), +// which is what keeps overlays from desyncing. +type Model struct { + width, height int + client *ws.Client + theme Theme + quitting bool + + // connection + connected bool + reconnecting bool + + // sessions + sessions []Session + selectedID string + filter string + workflows []Workflow + wfIndex int // -1 = not in workflow picker + wfVisible bool + bgUpdates int + + // input + editMode EditMode + inputMode InputMode + inputBuffer string + inputCursor int + history map[string][]string + historyIndex int + savedBuffer string + + // flow flags + sessionEntered bool + approvalDismissed bool + + // router transcript + routerMessages map[string][]RouterEntry + routerConnected bool + chatMode string + + // provider + currentModel string + providerType string // LOCAL | REMOTE + + // diff / overlay + overlay OverlayKind + overlayEventIdx int + diffScrollOffset int + eventStripShown bool + + // command palette + paletteFilter string + paletteIndex int + + // animation + frame int // tick counter; drives spinner + caret blink + + // snapshot phase + snapshotPhase bool + pendingEvents []protocol.ServerMessage + + // approval steering input buffer + steerBuffer string + steering bool +} + +// NewModel builds the initial idle state. +func NewModel(client *ws.Client) Model { + return Model{ + client: client, + theme: NewTheme(SoftBlue), + wfIndex: -1, + inputMode: ModeRouter, + history: map[string][]string{}, + historyIndex: -1, + routerMessages: map[string][]RouterEntry{}, + chatMode: ChatModeChat, + providerType: "LOCAL", + snapshotPhase: true, + eventStripShown: true, + } +} + +// displayState derives the active screen, matching the Kotlin extension. +func (m Model) displayState() DisplayState { + if m.selectedID == "" { + return StateIdle + } + hasApproval := false + if s := m.session(m.selectedID); s != nil && s.Pending != nil { + hasApproval = true + } + switch { + case m.approvalDismissed: + return StateInSession + case hasApproval: + return StateApproval + case m.sessionEntered: + return StateInSession + default: + return StateIdle + } +} + +func (m Model) session(id string) *Session { + for i := range m.sessions { + if m.sessions[i].ID == id { + return &m.sessions[i] + } + } + return nil +} + +// filteredSessions applies the workflow-id filter. +func (m Model) filteredSessions() []Session { + if m.filter == "" { + return m.sessions + } + out := make([]Session, 0, len(m.sessions)) + for _, s := range m.sessions { + if containsFold(s.WorkflowID, m.filter) { + out = append(out, s) + } + } + return out +} diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go new file mode 100644 index 00000000..bac13e5d --- /dev/null +++ b/apps/tui-go/internal/app/overlays.go @@ -0,0 +1,286 @@ +package app + +import ( + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// center composites a modal over a scrim-filled screen. Immediate-mode: the modal +// is recomputed from state every frame, so there is no separate show/restore path +// to desync (the "diff won't come back" bug class can't occur here). +func (m Model) center(modal string) string { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal, + lipgloss.WithWhitespaceBackground(m.theme.P.BgDeep), + lipgloss.WithWhitespaceForeground(m.theme.P.BgDeep)) +} + +func (m Model) modalWidth() int { + w := m.width * 70 / 100 + if w > 92 { + w = 92 + } + if w < 24 { + w = 24 + } + return w +} + +func (m Model) renderOverlay(base string) string { + switch m.overlay { + case OverlayPalette: + return m.center(m.paletteModal()) + case OverlayEventInspector: + return m.center(m.eventInspectorModal()) + case OverlayDiff: + return m.center(m.diffModal()) + case OverlayToolPalette: + return m.center(m.toolPaletteModal()) + } + return base +} + +func (m Model) titleLine(text string) string { + t := m.theme + return lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(text) +} + +func mbg(t Theme, s string, fg lipgloss.Color) string { + return lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(s) +} + +func (m Model) renderApproval(base string) string { + t := m.theme + s := m.session(m.selectedID) + if s == nil || s.Pending == nil { + return base + } + a := s.Pending + w := m.modalWidth() + textW := w - 6 + + riskColor := t.P.Warn + switch strings.ToUpper(a.Risk) { + case "HIGH", "CRITICAL": + riskColor = t.P.Bad + case "LOW": + riskColor = t.P.OK + } + + var b strings.Builder + b.WriteString(m.titleLine("approval required") + "\n\n") + b.WriteString(mbg(t, "tool ", t.P.Faint) + mbg(t, a.ToolName, t.P.FgStrong) + "\n") + b.WriteString(mbg(t, "tier ", t.P.Faint) + mbg(t, a.Tier, t.P.Accent2) + "\n") + b.WriteString(mbg(t, "risk ", t.P.Faint) + lipgloss.NewStyle().Foreground(riskColor).Background(t.P.BgPanel).Bold(true).Render(strings.ToUpper(a.Risk)) + "\n") + if a.Preview != "" { + b.WriteString("\n" + mbg(t, "preview", t.P.Faint) + "\n") + for _, ln := range limitLines(a.Preview, 6) { + b.WriteString(mbg(t, " "+truncate(ln, textW), t.P.Dim) + "\n") + } + } + b.WriteString("\n" + mbg(t, "steer ", t.P.Faint)) + if m.steerBuffer == "" { + b.WriteString(mbg(t, "(optional note)", t.P.Faint)) + } else { + b.WriteString(mbg(t, m.steerBuffer, t.P.FgStrong)) + } + b.WriteString(lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + "\n\n") + b.WriteString(modalHints(t, [][2]string{{"^a", "approve"}, {"^r", "reject"}, {"enter", "approve"}, {"^x", "diff"}, {"esc", "later"}})) + + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +func (m Model) diffModal() string { + t := m.theme + w := m.modalWidth() + h := m.height * 70 / 100 + if h < 8 { + h = 8 + } + bodyH := h - 6 + diff := m.currentDiff() + lines := strings.Split(strings.TrimRight(diff, "\n"), "\n") + + off := m.diffScrollOffset + if off > len(lines)-1 { + off = len(lines) - 1 + } + if off < 0 { + off = 0 + } + end := off + bodyH + if end > len(lines) { + end = len(lines) + } + + var b strings.Builder + b.WriteString(m.titleLine("diff") + mbg(t, " ("+itoa(len(lines))+" lines)", t.P.Faint) + "\n\n") + for _, ln := range lines[off:end] { + fg := t.P.Dim + switch { + case strings.HasPrefix(ln, "+"): + fg = t.P.OK + case strings.HasPrefix(ln, "-"): + fg = t.P.Bad + case strings.HasPrefix(ln, "@@"): + fg = t.P.Accent2 + } + b.WriteString(mbg(t, truncate(ln, w-6), fg) + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "scroll"}, {"^x/esc", "close"}})) + + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +func (m Model) eventInspectorModal() string { + t := m.theme + w := m.modalWidth() + evs := m.currentEvents() + + var b strings.Builder + b.WriteString(m.titleLine("event inspector") + "\n\n") + if len(evs) == 0 { + b.WriteString(mbg(t, "no events", t.P.Faint)) + } + // Budget the plain detail so the styled row never needs ANSI-aware truncation + // (truncating a styled string would cut through escape codes and corrupt it). + detailBudget := (w - 6) - (2 + 9 + 11 + 1 + 18 + 1) + if detailBudget < 4 { + detailBudget = 4 + } + for i, e := range evs { + cat := inferCategory(e.Type) + marker := mbg(t, " ", t.P.BgPanel) + fg := t.P.Fg + if i == m.overlayEventIdx { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ") + fg = t.P.FgStrong + } + row := marker + mbg(t, padRaw(e.Time, 8)+" ", t.P.Faint) + + lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel).Render(padRaw("["+cat+"]", 11)) + " " + + lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(padRaw(e.Type, 18)) + + mbg(t, " "+truncate(e.Detail, detailBudget), t.P.Dim) + b.WriteString(row + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"esc", "close"}})) + + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +func (m Model) paletteModal() string { + t := m.theme + w := m.modalWidth() + cmds := m.filteredPalette() + + var b strings.Builder + b.WriteString(m.titleLine("command palette") + "\n\n") + + // filter input line + filter := m.paletteFilter + caret := mbg(t, " ", t.P.BgPanel) + if m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("› ") + if filter == "" { + b.WriteString(prompt + caret + mbg(t, "type to filter…", t.P.Faint) + "\n\n") + } else { + b.WriteString(prompt + mbg(t, filter, t.P.FgStrong) + caret + "\n\n") + } + + if len(cmds) == 0 { + b.WriteString(mbg(t, " no matching commands", t.P.Faint) + "\n") + } + for i, c := range cmds { + marker := mbg(t, " ", t.P.BgPanel) + titleFg := t.P.Fg + if i == m.paletteIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ") + titleFg = t.P.FgStrong + } + b.WriteString(marker + + lipgloss.NewStyle().Foreground(titleFg).Background(t.P.BgPanel).Render(padRaw(c.title, 18)) + + mbg(t, " "+c.hint, t.P.Faint) + "\n") + } + b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "run"}, {"esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +func (m Model) toolPaletteModal() string { + t := m.theme + w := m.modalWidth() + s := m.session(m.selectedID) + + var b strings.Builder + b.WriteString(m.titleLine("tool palette") + "\n\n") + if s == nil || len(s.ToolsByStage) == 0 { + b.WriteString(mbg(t, "no tool manifest for this session", t.P.Faint) + "\n") + } else { + stages := make([]string, 0, len(s.ToolsByStage)) + for st := range s.ToolsByStage { + stages = append(stages, st) + } + sort.Strings(stages) + for _, st := range stages { + label := mbg(t, st, t.P.Accent2) + if st == s.CurrentStage { + label += lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render(" ◂ current") + } + b.WriteString(label + "\n") + for _, tool := range s.ToolsByStage[st] { + tier := lipgloss.NewStyle().Foreground(tierColor(t, tool.Tier)).Background(t.P.BgPanel).Render("T" + itoa(tool.Tier)) + b.WriteString(mbg(t, " "+padRaw(tool.Name, 22), t.P.Fg) + tier + "\n") + } + } + } + b.WriteString("\n" + modalHints(t, [][2]string{{"t/esc", "close"}})) + modal := t.Overlay.Width(w).Render(b.String()) + return m.center(modal) +} + +func tierColor(t Theme, tier int) lipgloss.Color { + switch { + case tier >= 3: + return t.P.Bad + case tier == 2: + return t.P.Warn + case tier == 1: + return t.P.Accent2 + default: + return t.P.OK + } +} + +func modalHints(t Theme, pairs [][2]string) string { + parts := make([]string, 0, len(pairs)) + for _, p := range pairs { + parts = append(parts, + lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Bold(true).Render(p[0])+ + mbg(t, " "+p[1], t.P.Faint)) + } + return strings.Join(parts, mbg(t, " ", t.P.Faint)) +} + +func limitLines(s string, n int) []string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[:n] + } + return lines +} + +func truncate(s string, w int) string { + if len([]rune(s)) <= w { + return s + } + r := []rune(s) + if w < 1 { + return "" + } + return string(r[:w-1]) + "…" +} diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go new file mode 100644 index 00000000..f09273b6 --- /dev/null +++ b/apps/tui-go/internal/app/server.go @@ -0,0 +1,331 @@ +package app + +import ( + "crypto/rand" + "encoding/hex" + "strings" + "time" + + "github.com/correx/tui-go/internal/protocol" +) + +func nowMillis() int64 { return time.Now().UnixMilli() } + +func newSessionID() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func containsFold(haystack, needle string) bool { + return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)) +} + +func formatTime(epochMillis int64) string { + return time.UnixMilli(epochMillis).UTC().Format("15:04:05") +} + +// inferCategory maps an event type string to a display category, matching the +// Kotlin inferCategory heuristic. +func inferCategory(t string) string { + lt := strings.ToLower(t) + switch { + case strings.Contains(lt, "approval"): + return "Approval" + case strings.Contains(lt, "infer"): + return "Inference" + case strings.Contains(lt, "tool"): + return "Tool" + case strings.Contains(lt, "context"): + return "Context" + case strings.Contains(lt, "stage"), strings.Contains(lt, "lifecycle"): + return "Lifecycle" + case strings.Contains(lt, "session"), strings.Contains(lt, "artifact"): + return "Domain" + default: + return "Lifecycle" + } +} + +// applyServer mutates the model for a single (non-buffered) server message. +func (m *Model) applyServer(msg protocol.ServerMessage) { + switch msg.Type { + case protocol.TypeSessionStarted: + m.onSessionStarted(msg) + case protocol.TypeSessionPaused: + label := "PAUSED" + if msg.Reason == "APPROVAL_PENDING" { + label = "PAUSED awaiting approval" + } + m.touch(msg.SessionID, label) + if s := m.session(msg.SessionID); s != nil { + s.Active = false + } + case protocol.TypeSessionResumed: + if s := m.session(msg.SessionID); s != nil { + s.Status = "ACTIVE" + s.Pending = nil + s.LastEventAt = nowMillis() + } + case protocol.TypeSessionCompleted: + m.touch(msg.SessionID, "COMPLETED") + if s := m.session(msg.SessionID); s != nil { + s.Active = false + } + case protocol.TypeSessionFailed: + m.touch(msg.SessionID, "FAILED") + if s := m.session(msg.SessionID); s != nil { + s.Active = false + } + if msg.SessionID == m.selectedID { + m.selectedID = "" + } + case protocol.TypeStageStarted: + if s := m.session(msg.SessionID); s != nil { + s.CurrentStage = msg.StageID + s.Tools = nil + s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID) + } + case protocol.TypeStageCompleted: + if s := m.session(msg.SessionID); s != nil { + s.CurrentStage = "" + s.addEvent(msg.OccurredAt, "StageCompleted", msg.StageID) + } + case protocol.TypeStageFailed: + if s := m.session(msg.SessionID); s != nil { + s.CurrentStage = "" + s.addEvent(msg.OccurredAt, "StageFailed", msg.StageID) + } + case protocol.TypeInferenceStarted: + if s := m.session(msg.SessionID); s != nil { + s.Active = true + s.addEvent(nowMillis(), "InferenceStarted", msg.StageID) + } + case protocol.TypeInferenceDone: + if s := m.session(msg.SessionID); s != nil { + s.Active = false + s.LastOutput = msg.Summary + if msg.Response != "" { + s.LastResponse = msg.Response + } + s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID) + } + case protocol.TypeInferenceTimeout: + if s := m.session(msg.SessionID); s != nil { + s.Active = false + s.addEvent(nowMillis(), "InferenceTimedOut", msg.StageID) + } + case protocol.TypeToolStarted: + if s := m.session(msg.SessionID); s != nil { + s.Active = true + s.Tools = append(s.Tools, ToolRecord{Name: msg.ToolName, Status: ToolStarted}) + if len(s.Tools) > 8 { + s.Tools = s.Tools[len(s.Tools)-8:] + } + s.LastEventAt = nowMillis() + } + case protocol.TypeToolCompleted: + if s := m.session(msg.SessionID); s != nil { + s.Active = false + s.markTool(msg.ToolName, ToolCompleted) + s.LastOutput = msg.ToolName + ": " + msg.Summary + s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName) + } + if msg.Diff != nil && *msg.Diff != "" { + m.appendRouter(msg.SessionID, RouterEntry{"tool", *msg.Diff}) + } + case protocol.TypeToolFailed: + if s := m.session(msg.SessionID); s != nil { + s.markTool(msg.ToolName, ToolFailed) + s.LastEventAt = msg.OccurredAt + } + case protocol.TypeToolRejected: + if s := m.session(msg.SessionID); s != nil { + s.markTool(msg.ToolName, ToolRejected) + s.LastEventAt = nowMillis() + } + case protocol.TypeArtifactCreated: + if s := m.session(msg.SessionID); s != nil { + s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID) + } + case protocol.TypeApprovalRequired: + m.onApprovalRequired(msg) + case protocol.TypeSessionSnapshot: + m.onSnapshot(msg) + case protocol.TypeStageManifest: + if s := m.session(msg.SessionID); s != nil { + byStage := map[string][]ManifestTool{} + for _, st := range msg.Stages { + tools := make([]ManifestTool, 0, len(st.Tools)) + for _, td := range st.Tools { + tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier}) + } + byStage[st.StageID] = tools + } + s.ToolsByStage = byStage + } + case protocol.TypeProviderStatus: + m.currentModel = msg.ProviderID + if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") { + m.providerType = "LOCAL" + } else { + m.providerType = "REMOTE" + } + case protocol.TypeRouterResponse: + m.routerConnected = true + m.appendRouter(msg.SessionID, RouterEntry{"router", msg.Content}) + case protocol.TypeWorkflowList: + m.workflows = m.workflows[:0] + for _, w := range msg.Workflows { + m.workflows = append(m.workflows, Workflow{ID: w.WorkflowID, Description: w.Description}) + } + } + + // Background-update badge for non-selected sessions. + if sid := sessionIDOf(msg); sid != "" && sid != m.selectedID { + m.bgUpdates++ + } +} + +func sessionIDOf(msg protocol.ServerMessage) string { + switch msg.Type { + case protocol.TypeStageManifest, protocol.TypeSnapshotComplete, + protocol.TypeProtocolError, protocol.TypeProviderStatus, + protocol.TypeWorkflowList, protocol.TypeRouterResponse: + return "" + default: + return msg.SessionID + } +} + +func (m *Model) onSessionStarted(msg protocol.ServerMessage) { + hadOptimistic := false + cleaned := m.sessions[:0:0] + for _, s := range m.sessions { + if s.Status == "STARTING" { + hadOptimistic = true + continue + } + cleaned = append(cleaned, s) + } + cleaned = append(cleaned, Session{ + ID: msg.SessionID, Status: "ACTIVE", WorkflowID: msg.WorkflowID, + Name: msg.WorkflowID, LastEventAt: nowMillis(), + }) + m.sessions = cleaned + if hadOptimistic || m.selectedID == "" { + m.selectedID = msg.SessionID + } + m.sessionEntered = true +} + +func (m *Model) onApprovalRequired(msg protocol.ServerMessage) { + risk := "unknown" + if msg.RiskSummary != nil { + risk = msg.RiskSummary.Level + } + info := &Approval{ + RequestID: msg.RequestID, + SessionID: msg.SessionID, + Tier: msg.Tier, + Risk: risk, + ToolName: deref(&msg.ToolName), + Preview: derefp(msg.Preview), + } + if s := m.session(msg.SessionID); s != nil { + s.Pending = info + } +} + +func (m *Model) onSnapshot(msg protocol.ServerMessage) { + var pending *Approval + if len(msg.PendingAppr) > 0 { + a := msg.PendingAppr[0] + pending = &Approval{ + RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier, + Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview), + } + } + status := "running" + if msg.State != nil { + status = msg.State.Status + } + if pending != nil { + status = "PAUSED awaiting approval" + } + sess := Session{ + ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID, + Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending, + } + if msg.State != nil && msg.State.CurrentStageID != nil { + sess.CurrentStage = *msg.State.CurrentStageID + } + for _, e := range msg.RecentEvents { + sess.Events = append(sess.Events, EventEntry{formatTime(e.Timestamp), e.Type, e.Detail}) + } + for _, t := range msg.Tools { + sess.Tools = append(sess.Tools, ToolRecord{Name: t.Name, Tier: t.Tier, Status: toolStatusOf(t.Status)}) + } + // Replace existing session with same id, else append. + replaced := false + for i := range m.sessions { + if m.sessions[i].ID == msg.SessionID { + m.sessions[i] = sess + replaced = true + break + } + } + if !replaced { + m.sessions = append(m.sessions, sess) + } + if m.selectedID == "" { + m.selectedID = msg.SessionID + } +} + +func (m *Model) touch(id, status string) { + if s := m.session(id); s != nil { + s.Status = status + s.LastEventAt = nowMillis() + } +} + +// --- Session helpers --- + +func (s *Session) addEvent(epochMillis int64, typ, detail string) { + s.Events = append(s.Events, EventEntry{formatTime(epochMillis), typ, detail}) + if len(s.Events) > 7 { + s.Events = s.Events[len(s.Events)-7:] + } + s.LastEventAt = epochMillis +} + +func (s *Session) markTool(name string, status ToolStatus) { + for i := range s.Tools { + if s.Tools[i].Name == name && s.Tools[i].Status == ToolStarted { + s.Tools[i].Status = status + } + } +} + +func toolStatusOf(s string) ToolStatus { + switch s { + case "COMPLETED": + return ToolCompleted + case "FAILED": + return ToolFailed + case "REJECTED": + return ToolRejected + default: + return ToolStarted + } +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func derefp(s *string) string { return deref(s) } diff --git a/apps/tui-go/internal/app/theme.go b/apps/tui-go/internal/app/theme.go new file mode 100644 index 00000000..3f996728 --- /dev/null +++ b/apps/tui-go/internal/app/theme.go @@ -0,0 +1,142 @@ +package app + +import "github.com/charmbracelet/lipgloss" + +// Palette is the "Soft" chrome from docs/visual/ref with the blue accent: +// rounded borders, opaque dark panels, generous spacing. +type Palette struct { + Bg lipgloss.Color // panel/background fill + BgDeep lipgloss.Color // outermost backdrop (slightly darker) + BgPanel lipgloss.Color // inside-panel fill + Sel lipgloss.Color // selection row background + Border lipgloss.Color // idle panel border + BorderHi lipgloss.Color // active panel border + Fg lipgloss.Color // primary text + FgStrong lipgloss.Color // headings / emphasis + Dim lipgloss.Color // labels, secondary text + Faint lipgloss.Color // tertiary, hints + Accent lipgloss.Color // blue accent + Accent2 lipgloss.Color // secondary accent + + OK lipgloss.Color + Warn lipgloss.Color + Bad lipgloss.Color + + CatLifecycle lipgloss.Color + CatContext lipgloss.Color + CatInference lipgloss.Color + CatTool lipgloss.Color + CatDomain lipgloss.Color + CatApproval lipgloss.Color +} + +// SoftBlue mirrors the reference Soft theme (#14161a bg, #ced3da fg) with the +// blue accent the user chose. +var SoftBlue = Palette{ + BgDeep: lipgloss.Color("#101216"), + Bg: lipgloss.Color("#14161a"), + BgPanel: lipgloss.Color("#171a1f"), + Sel: lipgloss.Color("#1b2733"), + Border: lipgloss.Color("#262b33"), + BorderHi: lipgloss.Color("#4a9eff"), + Fg: lipgloss.Color("#ced3da"), + FgStrong: lipgloss.Color("#e8edf2"), + Dim: lipgloss.Color("#7c8794"), + Faint: lipgloss.Color("#525c68"), + Accent: lipgloss.Color("#4a9eff"), + Accent2: lipgloss.Color("#56c8d8"), + + OK: lipgloss.Color("#7fd88f"), + Warn: lipgloss.Color("#e8c06a"), + Bad: lipgloss.Color("#e87f7f"), + + CatLifecycle: lipgloss.Color("#c98fd9"), + CatContext: lipgloss.Color("#b9c0c9"), + CatInference: lipgloss.Color("#56c8d8"), + CatTool: lipgloss.Color("#8fd96a"), + CatDomain: lipgloss.Color("#e8b765"), + CatApproval: lipgloss.Color("#e8c06a"), +} + +// Theme holds prebuilt lipgloss styles derived from a Palette. Every style sets +// a Background so the panels are fully opaque (no terminal wallpaper bleed). +type Theme struct { + P Palette + + Screen lipgloss.Style // full backdrop fill + Panel lipgloss.Style // idle rounded panel + PanelHi lipgloss.Style // active rounded panel + PanelTitle lipgloss.Style // uppercase dim header label + TitleHi lipgloss.Style // active header label + + Text lipgloss.Style + Strong lipgloss.Style + Dim lipgloss.Style + Faint lipgloss.Style + Accent lipgloss.Style + SelRow lipgloss.Style + Status lipgloss.Style // top bar + Footer lipgloss.Style // bottom hints + Input lipgloss.Style // input bar panel + Overlay lipgloss.Style // modal box + Scrim lipgloss.Style // dim backdrop behind modals +} + +// NewTheme builds the style set for a palette. +func NewTheme(p Palette) Theme { + base := lipgloss.NewStyle().Background(p.Bg).Foreground(p.Fg) + roundBase := func(border lipgloss.Color) lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(border). + BorderBackground(p.Bg). + Background(p.Bg). + Foreground(p.Fg). + Padding(0, 1) + } + return Theme{ + P: p, + Screen: lipgloss.NewStyle().Background(p.BgDeep).Foreground(p.Fg), + Panel: roundBase(p.Border), + PanelHi: roundBase(p.BorderHi), + PanelTitle: lipgloss.NewStyle().Background(p.Bg).Foreground(p.Dim).Bold(true), + TitleHi: lipgloss.NewStyle().Background(p.Bg).Foreground(p.Accent).Bold(true), + Text: base, + Strong: lipgloss.NewStyle().Background(p.Bg).Foreground(p.FgStrong).Bold(true), + Dim: lipgloss.NewStyle().Background(p.Bg).Foreground(p.Dim), + Faint: lipgloss.NewStyle().Background(p.Bg).Foreground(p.Faint), + Accent: lipgloss.NewStyle().Background(p.Bg).Foreground(p.Accent).Bold(true), + SelRow: lipgloss.NewStyle().Background(p.Sel).Foreground(p.FgStrong), + Status: lipgloss.NewStyle().Background(p.BgPanel).Foreground(p.Dim), + Footer: lipgloss.NewStyle().Background(p.BgDeep).Foreground(p.Faint), + Input: roundBase(p.BorderHi), + Overlay: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(p.Accent). + BorderBackground(p.BgPanel). + Background(p.BgPanel). + Foreground(p.Fg). + Padding(1, 2), + Scrim: lipgloss.NewStyle().Background(p.BgDeep).Foreground(p.Faint), + } +} + +// categoryColor maps an event category label to its accent color. +func (t Theme) categoryColor(cat string) lipgloss.Color { + switch cat { + case "Lifecycle": + return t.P.CatLifecycle + case "Context": + return t.P.CatContext + case "Inference": + return t.P.CatInference + case "Tool": + return t.P.CatTool + case "Domain": + return t.P.CatDomain + case "Approval": + return t.P.CatApproval + default: + return t.P.Dim + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go new file mode 100644 index 00000000..77a94481 --- /dev/null +++ b/apps/tui-go/internal/app/update.go @@ -0,0 +1,665 @@ +package app + +import ( + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/correx/tui-go/internal/protocol" + "github.com/correx/tui-go/internal/ws" +) + +// --- Bubble Tea messages bridging the ws channels --- + +type serverMsg struct{ m protocol.ServerMessage } +type connMsg struct{ s ws.Status } +type tickMsg struct{} + +func readServer(c *ws.Client) tea.Cmd { + return func() tea.Msg { return serverMsg{<-c.Incoming()} } +} + +func readConn(c *ws.Client) tea.Cmd { + return func() tea.Msg { return connMsg{<-c.Conn()} } +} + +func tick() tea.Cmd { + return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return tickMsg{} }) +} + +// Init starts the channel readers and the cursor-blink tick. +func (m Model) Init() tea.Cmd { + return tea.Batch(readServer(m.client), readConn(m.client), tick()) +} + +// Update is the single reducer. View renders purely from the returned model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tickMsg: + m.frame++ + return m, tick() + + case connMsg: + m.applyConn(msg.s) + return m, readConn(m.client) + + case serverMsg: + m.applyServerPhased(msg.m) + return m, readServer(m.client) + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m *Model) applyConn(s ws.Status) { + switch { + case s.Connected: + m.connected = true + m.reconnecting = false + m.snapshotPhase = true + m.pendingEvents = nil + case s.Reconnecting: + m.connected = false + m.reconnecting = true + m.snapshotPhase = true + } +} + +// applyServerPhased buffers event-bearing messages during the snapshot phase and +// drains them atomically on snapshot_complete. +func (m *Model) applyServerPhased(msg protocol.ServerMessage) { + if msg.Type == protocol.TypeSnapshotComplete { + m.snapshotPhase = false + buffered := m.pendingEvents + m.pendingEvents = nil + for _, e := range buffered { + m.applyServer(e) + } + return + } + if m.snapshotPhase && msg.IsEventBearing() { + m.pendingEvents = append(m.pendingEvents, msg) + return + } + m.applyServer(msg) +} + +// --- key handling --- + +func (m Model) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + // Ctrl+C is a universal hard-quit safety; everything else is bare-key modal. + if k.Type == tea.KeyCtrlC { + m.quitting = true + return m, tea.Quit + } + if m.overlay != OverlayNone { + return m.handleOverlayKey(k) + } + if m.editMode == ModeInsert { + return m.handleInsertKey(k) + } + return m.handleNormalKey(k) +} + +// handleNormalKey processes vim-style bare-key commands. +func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + ds := m.displayState() + switch k.Type { + case tea.KeyUp: + m.navUp() + return m, nil + case tea.KeyDown: + m.navDown() + return m, nil + case tea.KeyEnter: + return m.normalEnter() + case tea.KeyEsc: + m.filter = "" + return m, nil + } + if k.Type != tea.KeyRunes { + return m, nil + } + switch string(k.Runes) { + case "i": + m.enterInsert(ModeRouter) + case "/": + m.enterInsert(ModeFilter) + case "j": + m.navDown() + case "k": + m.navUp() + case "p": + m.overlay = OverlayPalette + m.paletteFilter = "" + m.paletteIndex = 0 + case "e": + m.overlay = OverlayEventInspector + m.overlayEventIdx = 0 + case "t": + m.overlay = OverlayToolPalette + case "w": + if ds == StateIdle { + m.wfVisible = !m.wfVisible + if m.wfVisible { + m.wfIndex = 0 + } else { + m.wfIndex = -1 + } + } + case "l": + if ds != StateIdle { + m.sessionEntered = false + m.approvalDismissed = false + } + case "s": + if ds == StateApproval { + m.steering = true + m.editMode = ModeInsert + } else { + m.cycleChatMode() + } + case "x": + if m.currentDiff() != "" { + m.overlay = OverlayDiff + m.diffScrollOffset = 0 + } + case "c": + if m.selectedID != "" { + m.client.Send(protocol.CancelSession(m.selectedID)) + } + case "a": + if ds == StateApproval { + return m.decide("APPROVE") + } + case "A": + if ds == StateApproval { + return m.autoApprove() + } + case "r": + if ds == StateApproval { + return m.decide("REJECT") + } + case "q": + m.quitting = true + return m, tea.Quit + } + return m, nil +} + +// handleInsertKey processes typing (chat/filter/steer note). +func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.steering { + switch k.Type { + case tea.KeyEsc: + m.steering = false + m.editMode = ModeNormal + case tea.KeyEnter: + return m.submitApproval() + case tea.KeyBackspace: + if n := len(m.steerBuffer); n > 0 { + m.steerBuffer = m.steerBuffer[:n-1] + } + case tea.KeyRunes, tea.KeySpace: + m.steerBuffer += string(k.Runes) + } + return m, nil + } + + switch k.Type { + case tea.KeyEsc: + m.editMode = ModeNormal + if m.inputMode == ModeFilter { + m.inputMode = ModeRouter + } + case tea.KeyEnter: + return m.submit() + case tea.KeyBackspace: + m.backspace() + m.syncFilter() + case tea.KeyLeft: + if m.inputCursor > 0 { + m.inputCursor-- + } + case tea.KeyRight: + if m.inputCursor < len(m.inputBuffer) { + m.inputCursor++ + } + case tea.KeyUp: + if m.inputMode == ModeFilter { + m.listNav(-1) + } else { + m.historyPrev() + } + case tea.KeyDown: + if m.inputMode == ModeFilter { + m.listNav(1) + } else { + m.historyNext() + } + case tea.KeyRunes, tea.KeySpace: + m.appendRunes(string(k.Runes)) + m.syncFilter() + } + return m, nil +} + +func (m *Model) enterInsert(mode InputMode) { + m.editMode = ModeInsert + m.inputMode = mode + if mode == ModeFilter { + m.inputBuffer = m.filter + m.inputCursor = len(m.inputBuffer) + } +} + +// syncFilter keeps the live session filter in step with the input buffer. +func (m *Model) syncFilter() { + if m.inputMode == ModeFilter { + m.filter = m.inputBuffer + } +} + +func (m Model) normalEnter() (tea.Model, tea.Cmd) { + if m.displayState() != StateIdle { + return m, nil + } + if m.wfVisible && m.wfIndex >= 0 && m.wfIndex < len(m.workflows) { + wf := m.workflows[m.wfIndex] + m.client.Send(protocol.StartSession(wf.ID)) + m.wfVisible = false + m.wfIndex = -1 + return m, nil + } + if m.selectedID != "" { + m.sessionEntered = true + } + return m, nil +} + +func (m Model) autoApprove() (tea.Model, tea.Cmd) { + s := m.session(m.selectedID) + if s == nil || s.Pending == nil { + return m, nil + } + p := s.Pending + m.client.Send(protocol.CreateGrant(p.SessionID, "SESSION", []string{p.Tier}, "auto-approved via TUI", p.ToolName)) + m.client.Send(protocol.ApprovalResponse(p.RequestID, "APPROVE", nil)) + s.Pending = nil + m.steerBuffer = "" + m.steering = false + m.approvalDismissed = false + return m, nil +} + +func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + if k.Type == tea.KeyEsc { + m.overlay = OverlayNone + return m, nil + } + switch m.overlay { + case OverlayPalette: + return m.handlePaletteKey(k) + case OverlayDiff: + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.diffScrollOffset > 0 { + m.diffScrollOffset-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + m.diffScrollOffset++ + case runeIs(k, "x"): + m.overlay = OverlayNone + } + case OverlayEventInspector: + evs := m.currentEvents() + switch { + case k.Type == tea.KeyUp || runeIs(k, "k"): + if m.overlayEventIdx > 0 { + m.overlayEventIdx-- + } + case k.Type == tea.KeyDown || runeIs(k, "j"): + if m.overlayEventIdx < len(evs)-1 { + m.overlayEventIdx++ + } + } + case OverlayToolPalette: + if runeIs(k, "t") { + m.overlay = OverlayNone + } + } + return m, nil +} + +func runeIs(k tea.KeyMsg, s string) bool { + return k.Type == tea.KeyRunes && string(k.Runes) == s +} + +func (m Model) handlePaletteKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { + cmds := m.filteredPalette() + switch k.Type { + case tea.KeyUp: + if m.paletteIndex > 0 { + m.paletteIndex-- + } + case tea.KeyDown: + if m.paletteIndex < len(cmds)-1 { + m.paletteIndex++ + } + case tea.KeyEnter: + if m.paletteIndex >= 0 && m.paletteIndex < len(cmds) { + return m.execPalette(cmds[m.paletteIndex].id) + } + case tea.KeyBackspace: + if n := len(m.paletteFilter); n > 0 { + m.paletteFilter = m.paletteFilter[:n-1] + m.paletteIndex = 0 + } + case tea.KeyRunes, tea.KeySpace: + m.paletteFilter += string(k.Runes) + m.paletteIndex = 0 + } + return m, nil +} + +type paletteCmd struct{ id, title, hint string } + +func paletteCommands() []paletteCmd { + return []paletteCmd{ + {"workflows", "start workflow", "open the workflow picker"}, + {"tools", "tool palette", "tools for the current stage"}, + {"events", "event inspector", "browse the event stream"}, + {"mode", "toggle mode", "switch chat / steering"}, + {"cancel", "cancel session", "stop the selected session"}, + {"back", "back to list", "leave the current session"}, + {"quit", "quit", "exit correx"}, + } +} + +func (m Model) filteredPalette() []paletteCmd { + f := strings.ToLower(m.paletteFilter) + if f == "" { + return paletteCommands() + } + var out []paletteCmd + for _, c := range paletteCommands() { + if strings.Contains(strings.ToLower(c.title+" "+c.hint), f) { + out = append(out, c) + } + } + return out +} + +func (m Model) execPalette(id string) (tea.Model, tea.Cmd) { + m.overlay = OverlayNone + switch id { + case "workflows": + m.wfVisible = true + m.wfIndex = 0 + case "tools": + m.overlay = OverlayToolPalette + case "events": + m.overlay = OverlayEventInspector + m.overlayEventIdx = 0 + case "mode": + m.cycleChatMode() + case "cancel": + if m.selectedID != "" { + m.client.Send(protocol.CancelSession(m.selectedID)) + } + case "back": + m.sessionEntered = false + m.approvalDismissed = false + case "quit": + m.quitting = true + return m, tea.Quit + } + return m, nil +} + +// --- input editing --- + +func (m *Model) appendRunes(s string) { + b := m.inputBuffer + c := m.inputCursor + if c > len(b) { + c = len(b) + } + m.inputBuffer = b[:c] + s + b[c:] + m.inputCursor = c + len(s) +} + +func (m *Model) backspace() { + if m.inputCursor == 0 || len(m.inputBuffer) == 0 { + return + } + c := m.inputCursor + m.inputBuffer = m.inputBuffer[:c-1] + m.inputBuffer[c:] + m.inputCursor = c - 1 +} + +func (m *Model) cycleChatMode() { + if m.chatMode == ChatModeChat { + m.chatMode = ChatModeSteering + } else { + m.chatMode = ChatModeChat + } +} + +func (m *Model) clearInput() { + m.inputBuffer = "" + m.inputCursor = 0 + m.historyIndex = -1 +} + +// --- submit (IDLE -> StartChat / StartSession, IN_SESSION -> ChatInput) --- + +func (m Model) submit() (tea.Model, tea.Cmd) { + ds := m.displayState() + if m.inputMode == ModeFilter { + m.filter = m.inputBuffer + m.clearInput() + m.inputMode = ModeRouter + m.editMode = ModeNormal + return m, nil + } + text := strings.TrimSpace(m.inputBuffer) + + switch ds { + case StateIdle: + // Workflow picker selection. + if m.wfIndex >= 0 && m.wfIndex < len(m.workflows) { + wf := m.workflows[m.wfIndex] + m.wfIndex = -1 + m.client.Send(protocol.StartSession(wf.ID)) + m.clearInput() + return m, nil + } + // Entering an already-selected session (blank submit). + if text == "" && m.selectedID != "" { + m.sessionEntered = true + m.clearInput() + return m, nil + } + if text == "" { + return m, nil + } + // New optimistic chat session. + id := newSessionID() + m.sessions = append(m.sessions, Session{ + ID: id, Status: "STARTING", WorkflowID: "chat", Name: "chat", + LastEventAt: nowMillis(), + }) + m.selectedID = id + m.sessionEntered = true + m.appendRouter(id, RouterEntry{"user", text}) + m.client.Send(protocol.StartChatSession(id, text)) + m.clearInput() + return m, nil + + case StateInSession: + if text == "" || m.selectedID == "" { + return m, nil + } + sid := m.selectedID + hist := m.history[sid] + hist = append(hist, text) + if len(hist) > 50 { + hist = hist[len(hist)-50:] + } + m.history[sid] = hist + m.appendRouter(sid, RouterEntry{"user", text}) + m.client.Send(protocol.ChatInput(sid, text, m.chatMode)) + m.clearInput() + return m, nil + } + return m, nil +} + +func (m Model) decide(decision string) (tea.Model, tea.Cmd) { + s := m.session(m.selectedID) + if s == nil || s.Pending == nil { + return m, nil + } + var note *string + if strings.TrimSpace(m.steerBuffer) != "" { + n := m.steerBuffer + note = &n + } + m.client.Send(protocol.ApprovalResponse(s.Pending.RequestID, decision, note)) + s.Pending = nil + m.steerBuffer = "" + m.steering = false + m.editMode = ModeNormal + m.approvalDismissed = false + return m, nil +} + +func (m Model) submitApproval() (tea.Model, tea.Cmd) { + // Enter approves by default; steering note (if any) rides along. + return m.decide("APPROVE") +} + +// --- navigation (list / history) --- + +func (m *Model) navUp() { + if m.displayState() == StateInSession && m.inputMode == ModeRouter { + m.historyPrev() + return + } + if m.wfVisible { + m.wfNav(-1) + return + } + m.listNav(-1) +} + +func (m *Model) navDown() { + if m.displayState() == StateInSession && m.inputMode == ModeRouter { + m.historyNext() + return + } + if m.wfVisible { + m.wfNav(1) + return + } + m.listNav(1) +} + +func (m *Model) wfNav(dir int) { + n := len(m.workflows) + if n == 0 { + return + } + m.wfIndex = (m.wfIndex + dir + n) % n +} + +func (m *Model) listNav(dir int) { + list := m.filteredSessions() + if len(list) == 0 { + return + } + idx := -1 + for i, s := range list { + if s.ID == m.selectedID { + idx = i + break + } + } + n := idx + dir + if n < 0 { + n = len(list) - 1 + } + if n >= len(list) { + n = 0 + } + if m.selectedID != list[n].ID { + m.bgUpdates = 0 + } + m.selectedID = list[n].ID + m.wfIndex = -1 +} + +func (m *Model) historyPrev() { + hist := m.history[m.selectedID] + if len(hist) == 0 { + return + } + switch m.historyIndex { + case -1: + m.savedBuffer = m.inputBuffer + m.historyIndex = len(hist) - 1 + case 0: + return + default: + m.historyIndex-- + } + m.inputBuffer = hist[m.historyIndex] + m.inputCursor = len(m.inputBuffer) +} + +func (m *Model) historyNext() { + hist := m.history[m.selectedID] + switch { + case m.historyIndex == -1: + return + case m.historyIndex == len(hist)-1: + m.historyIndex = -1 + m.inputBuffer = m.savedBuffer + default: + m.historyIndex++ + m.inputBuffer = hist[m.historyIndex] + } + m.inputCursor = len(m.inputBuffer) +} + +func (m *Model) appendRouter(sid string, e RouterEntry) { + m.routerMessages[sid] = append(m.routerMessages[sid], e) +} + +// currentDiff returns the most recent tool diff in the selected transcript. +func (m Model) currentDiff() string { + if s := m.session(m.selectedID); s != nil && s.Pending != nil && s.Pending.Preview != "" { + return s.Pending.Preview + } + msgs := m.routerMessages[m.selectedID] + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == "tool" { + return msgs[i].Content + } + } + return "" +} + +func (m Model) currentEvents() []EventEntry { + if s := m.session(m.selectedID); s != nil { + return s.Events + } + return nil +} diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go new file mode 100644 index 00000000..976b97b5 --- /dev/null +++ b/apps/tui-go/internal/app/view.go @@ -0,0 +1,548 @@ +package app + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +const ( + statusH = 1 + footerH = 1 + inputH = 4 // rounded box: 2 borders + 2 content lines +) + +// View renders the whole frame purely from the model (immediate-mode). +func (m Model) View() string { + if m.quitting { + return "" + } + if m.width < 20 || m.height < 8 { + return m.theme.Screen.Render("correx — terminal too small") + } + + mainH := m.height - statusH - footerH - inputH + if mainH < 3 { + mainH = 3 + } + + base := lipgloss.JoinVertical(lipgloss.Left, + m.renderStatus(), + m.renderMain(mainH), + m.renderInput(), + m.renderFooter(), + ) + + if m.overlay != OverlayNone { + return m.renderOverlay(base) + } + if m.displayState() == StateApproval { + return m.renderApproval(base) + } + return base +} + +// --- top status bar --- + +func (m Model) renderStatus() string { + t := m.theme + bg := t.P.BgPanel + span := func(s string, fg lipgloss.Color) string { + return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s) + } + sep := span(" · ", t.P.Faint) + + parts := []string{span("correx", t.P.Dim)} + if m.connected { + parts = append(parts, span("●", t.P.OK)+span(" connected", t.P.Dim)) + } else if m.reconnecting { + parts = append(parts, span("◌", t.P.Warn)+span(" reconnecting", t.P.Warn)) + } else { + parts = append(parts, span("○", t.P.Bad)+span(" disconnected", t.P.Bad)) + } + + if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { + parts = append(parts, span(s.Name, t.P.FgStrong)) + if s.CurrentStage != "" { + parts = append(parts, span(s.CurrentStage, t.P.Accent2)) + } + } + + model := m.currentModel + if model == "" { + model = "no model" + } + loc := "local" + if m.providerType == "REMOTE" { + loc = "remote" + } + parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint)) + left := strings.Join(parts, sep) + + var right []string + if s := m.session(m.selectedID); s != nil && s.Active { + right = append(right, lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render(m.spinner())+span(" active", t.P.Accent2)) + } + if m.bgUpdates > 0 { + right = append(right, span(itoa(m.bgUpdates)+" bg", t.P.Warn)) + } + if len(right) == 0 { + return m.fill(left, m.width, bg) + } + return m.justify(left, strings.Join(right, span(" · ", t.P.Faint)), m.width, bg) +} + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +func (m Model) spinner() string { return spinnerFrames[m.frame%len(spinnerFrames)] } + +// caretVisible blinks the insert caret roughly twice a second. +func (m Model) caretVisible() bool { return (m.frame/4)%2 == 0 } + +// --- bottom footer hints --- + +func (m Model) renderFooter() string { + t := m.theme + bg := t.P.BgDeep + hint := func(key, label string) string { + k := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Bold(true).Render(key) + l := lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" " + label) + return k + l + } + gap := lipgloss.NewStyle().Background(bg).Render(" ") + + var hints []string + switch { + case m.editMode == ModeInsert: + hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} + case m.displayState() == StateApproval: + hints = []string{hint("a", "approve"), hint("A", "auto"), hint("s", "steer"), hint("r", "reject"), hint("x", "diff"), hint("esc", "later")} + case m.displayState() == StateIdle: + hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("p", "cmds"), hint("q", "quit")} + default: // StateInSession + hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} + } + left := strings.Join(hints, gap) + + right := lipgloss.NewStyle().Foreground(t.P.Dim).Background(bg).Render("Soft") + + lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" · ") + + lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render("blue") + + return m.justify(left, right, m.width, bg) +} + +// --- main split --- + +func (m Model) renderMain(h int) string { + leftW := m.width * 63 / 100 + rightW := m.width - leftW + + var leftTitle, rightTitle string + var leftBody, rightBody []string + leftActive, rightActive := false, false + + switch m.displayState() { + case StateIdle: + leftTitle = "sessions" + if m.wfVisible { + leftTitle = "workflows" + leftBody = m.workflowRows(leftW - 4) + } else { + leftBody = m.sessionRows(leftW - 4) + } + leftActive = true + rightTitle = "welcome" + rightBody = m.welcomeRows(rightW - 4) + case StateInSession, StateApproval: + leftTitle = "output" + leftBody = m.routerRows(leftW-4, h-2) + leftActive = true + rightTitle = "events" + rightBody = m.eventRows(rightW - 4) + } + + left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive) + right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive) + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} + +// --- input bar --- + +func (m Model) renderInput() string { + t := m.theme + + placeholder := "Ask anything…" + switch { + case m.inputMode == ModeFilter: + placeholder = "Filter sessions…" + case m.displayState() == StateIdle: + placeholder = "Session name…" + } + + insert := m.editMode == ModeInsert + caret := t.span(" ", t.P.Bg) + if insert && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") + } + var line string + switch { + case m.inputBuffer == "" && !insert: + line = t.span(placeholder, t.P.Faint) + case m.inputBuffer == "": + line = caret + t.span(placeholder, t.P.Faint) + default: + line = t.span(m.inputBuffer, t.P.FgStrong) + caret + } + + // status sub-line: · + name := "(no session)" + if s := m.session(m.selectedID); s != nil { + name = s.Name + } + mode := "chat" + if m.chatMode == ChatModeSteering { + mode = "steering" + } + if m.inputMode == ModeFilter { + mode = "filter" + } + sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name) + + t.span(" · ", t.P.Faint) + + t.span(mode, t.P.Dim) + + body := []string{line, sub} + return t.box("", body, m.width, inputH, insert) +} + +// --- body row builders --- + +func (m Model) sessionRows(w int) []string { + t := m.theme + list := m.filteredSessions() + if len(list) == 0 { + return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)} + } + rows := make([]string, 0, len(list)) + for _, s := range list { + sel := s.ID == m.selectedID && !m.wfVisible + short := s.ID + if len(short) > 6 { + short = short[:6] + } + nameFg := t.P.Fg + bar := t.span(" ", t.P.Bg) + if sel { + bar = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▌ ") + nameFg = t.P.FgStrong + } + idCell := t.span("["+short+"] ", t.P.Faint) + statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true). + Render(padRaw(statusLabel(s.Status), 9)) + nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + s.Name) + stage := "" + if s.CurrentStage != "" { + stage = t.span(" ["+s.CurrentStage+"]", t.P.Dim) + } + rows = append(rows, bar+idCell+statusCell+nameCell+stage) + } + return rows +} + +func (m Model) workflowRows(w int) []string { + t := m.theme + if len(m.workflows) == 0 { + return []string{t.span("no workflows advertised", t.P.Faint)} + } + rows := make([]string, 0, len(m.workflows)) + for i, wf := range m.workflows { + marker := t.span(" ", t.P.Bg) + fg := t.P.Fg + if i == m.wfIndex { + marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▸ ") + fg = t.P.FgStrong + } + rows = append(rows, marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(wf.ID)+t.span(" "+wf.Description, t.P.Faint)) + } + return rows +} + +func (m Model) welcomeRows(w int) []string { + t := m.theme + title := lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render("Corre") + + lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("x") + + lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render(" Agent Harness") + + var status string + if m.connected { + status = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("Connected") + + t.span(" · ", t.P.Faint) + t.span(plural(len(m.sessions), "session"), t.P.Dim) + } else { + status = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("Disconnected") + } + + rows := []string{title, status, "", + t.span("Select a session from the left", t.P.Dim), + t.span("or type a name to start a new one.", t.P.Dim), ""} + + if n := len(m.sessions); n > 0 { + rows = append(rows, t.span("recent:", t.P.Faint)) + start := 0 + if n > 5 { + start = n - 5 + } + for _, s := range m.sessions[start:] { + mark := statusGlyph(t, s.Status) + rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(formatTime(s.LastEventAt), t.P.Faint)+" "+mark) + } + } + return rows +} + +func (m Model) routerRows(w, h int) []string { + t := m.theme + msgs := m.routerMessages[m.selectedID] + if len(msgs) == 0 { + return []string{t.span("awaiting router response…", t.P.Faint)} + } + var rows []string + for _, e := range msgs { + switch e.Role { + case "user": + tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›") + rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong)) + case "router": + for _, ln := range wrap(e.Content, w) { + rows = append(rows, t.span(ln, t.P.Fg)) + } + case "tool": + rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim)) + } + } + // keep last h rows + if len(rows) > h { + rows = rows[len(rows)-h:] + } + return rows +} + +func (m Model) eventRows(w int) []string { + t := m.theme + header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") + + t.span(" ", t.P.Bg) + if m.connected { + header += lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live") + } else { + header += t.span("○ idle", t.P.Faint) + } + + rows := []string{header, ""} + s := m.session(m.selectedID) + if s == nil || len(s.Events) == 0 { + rows = append(rows, t.span("no events yet", t.P.Faint)) + return rows + } + for _, e := range s.Events { + cat := inferCategory(e.Type) + catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel). + Render(" " + padRaw(cat, 9) + " ") + rows = append(rows, + t.span(e.Time+" ", t.P.Faint)+catCell+" "+ + t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim)) + } + return rows +} + +// --- width helpers --- + +// fill left-justifies a styled line and pads with bg to width w. +func (m Model) fill(s string, w int, bg lipgloss.Color) string { + return " " + padTo(s, w-1, bg) +} + +// justify places left and right segments on a bg-filled line of width w. +func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { + lw := lipgloss.Width(left) + rw := lipgloss.Width(right) + mid := w - lw - rw - 2 + if mid < 1 { + mid = 1 + } + pad := lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", mid)) + return " " + left + pad + right + lipgloss.NewStyle().Background(bg).Render(" ") +} + +// padTo pads (or truncates) a possibly-styled string to visible width w, filling +// with the given background so the panel stays opaque. +func padTo(s string, w int, bg lipgloss.Color) string { + vw := lipgloss.Width(s) + if vw == w { + return s + } + if vw > w { + return ansi.Truncate(s, w, "") + } + return s + lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", w-vw)) +} + +// padRaw pads a plain (unstyled) string to width w with spaces, truncating with +// an ellipsis only when it genuinely overflows. +func padRaw(s string, w int) string { + n := len([]rune(s)) + if n == w { + return s + } + if n > w { + if w <= 1 { + return string([]rune(s)[:w]) + } + return string([]rune(s)[:w-1]) + "…" + } + return s + strings.Repeat(" ", w-n) +} + +func statusColor(t Theme, status string) lipgloss.Color { + u := strings.ToUpper(status) + switch { + case strings.Contains(u, "FAIL"): + return t.P.Bad + case strings.Contains(u, "COMPLETE"): + return t.P.Dim + case strings.Contains(u, "PAUSE"), strings.Contains(u, "STARTING"): + return t.P.Warn + case strings.Contains(u, "CHAT"), strings.Contains(u, "ACTIVE"): + return t.P.Accent + default: + return t.P.Dim + } +} + +func statusLabel(status string) string { + u := strings.ToUpper(status) + if i := strings.Index(u, " "); i > 0 { + return u[:i] + } + return u +} + +func statusGlyph(t Theme, status string) string { + u := strings.ToUpper(status) + switch { + case strings.Contains(u, "FAIL"): + return lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("✗") + case strings.Contains(u, "COMPLETE"): + return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("✓") + default: + return t.span("·", t.P.Faint) + } +} + +// span renders text with foreground fg over the panel background. +func (t Theme) span(s string, fg lipgloss.Color) string { + return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s) +} + +// box draws a rounded, opaque panel with the title embedded in the top border. +func (t Theme) box(title string, body []string, w, h int, active bool) string { + if w < 4 { + w = 4 + } + if h < 2 { + h = 2 + } + inner := w - 2 + textW := inner - 2 + bc := t.P.Border + if active { + bc = t.P.BorderHi + } + bs := lipgloss.NewStyle().Foreground(bc).Background(t.P.Bg) + cell := lipgloss.NewStyle().Background(t.P.Bg) + + var top string + if title == "" { + top = bs.Render("╭" + strings.Repeat("─", inner) + "╮") + } else { + ts := t.PanelTitle + if active { + ts = t.TitleHi + } + label := strings.ToUpper(title) + fill := inner - (2 + lipgloss.Width(label) + 1) + if fill < 0 { + fill = 0 + } + top = bs.Render("╭─ ") + ts.Render(label) + bs.Render(" "+strings.Repeat("─", fill)) + bs.Render("╮") + } + lines := make([]string, 0, h) + lines = append(lines, top) + for i := 0; i < h-2; i++ { + content := "" + if i < len(body) { + content = body[i] + } + padded := padTo(content, textW, t.P.Bg) + lines = append(lines, bs.Render("│")+cell.Render(" ")+padded+cell.Render(" ")+bs.Render("│")) + } + lines = append(lines, bs.Render("╰"+strings.Repeat("─", inner)+"╯")) + return strings.Join(lines, "\n") +} + +func plural(n int, word string) string { + s := itoa(n) + " " + word + if n != 1 { + s += "s" + } + return s +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} + +func itoaLen(s string) string { return itoa(len(s)) } + +// wrap splits s into lines no wider than w (rune-based, whitespace-greedy). +func wrap(s string, w int) []string { + if w < 1 { + w = 1 + } + words := strings.Fields(s) + if len(words) == 0 { + return []string{""} + } + var lines []string + cur := "" + for _, word := range words { + if cur == "" { + cur = word + } else if len(cur)+1+len(word) <= w { + cur += " " + word + } else { + lines = append(lines, cur) + cur = word + } + } + if cur != "" { + lines = append(lines, cur) + } + return lines +} diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go new file mode 100644 index 00000000..ef94e841 --- /dev/null +++ b/apps/tui-go/internal/protocol/protocol.go @@ -0,0 +1,211 @@ +// Package protocol implements the correx WebSocket wire contract used to talk +// to apps/server over /stream. The server (Kotlin/kotlinx.serialization) uses a +// "type" class discriminator. Server->client variants carry @SerialName values +// (e.g. "session.started"); client->server variants have no @SerialName, so the +// discriminator is the fully-qualified Kotlin class name. +package protocol + +import "encoding/json" + +// clientPrefix is the kotlinx default serial name for nested ClientMessage +// subclasses (qualified class name). The server decodes against this exact value. +const clientPrefix = "com.correx.apps.server.protocol.ClientMessage." + +// Server message discriminator values (@SerialName on ServerMessage variants). +const ( + TypeSessionStarted = "session.started" + TypeSessionPaused = "session.paused" + TypeSessionResumed = "session.resumed" + TypeSessionCompleted = "session.completed" + TypeSessionFailed = "session.failed" + TypeSessionSnapshot = "session_snapshot" + TypeStageStarted = "stage.started" + TypeStageCompleted = "stage.completed" + TypeStageFailed = "stage.failed" + TypeInferenceStarted = "inference.started" + TypeInferenceDone = "inference.completed" + TypeInferenceTimeout = "inference.timed_out" + TypeToolStarted = "tool.started" + TypeToolCompleted = "tool.completed" + TypeToolFailed = "tool.failed" + TypeToolRejected = "tool.rejected" + TypeApprovalRequired = "approval.required" + TypeStageManifest = "stage.tool_manifest" + TypeProviderStatus = "provider.status_changed" + TypeProtocolError = "protocol_error" + TypeArtifactCreated = "artifact.created" + TypeRouterResponse = "router.response" + TypeSnapshotComplete = "snapshot_complete" + TypeWorkflowList = "workflow.list" +) + +// ServerMessage is a flat decode of every server->client variant. Field names +// across variants don't collide in incompatible JSON types, so a single struct +// keyed by Type decodes the whole protocol. Value-class IDs serialize as plain +// strings (verified against ServerMessageSerializationTest). +type ServerMessage struct { + Type string `json:"type"` + + SessionID string `json:"sessionId"` + WorkflowID string `json:"workflowId"` + StageID string `json:"stageId"` + ToolName string `json:"toolName"` + Reason string `json:"reason"` + Summary string `json:"outputSummary"` + Response string `json:"responseText"` + Content string `json:"content"` + Diff *string `json:"diff"` + Tier string `json:"tier"` + Message string `json:"message"` + RequestID string `json:"requestId"` + ArtifactID string `json:"artifactId"` + ProviderID string `json:"providerId"` + Preview *string `json:"preview"` + + SteeringEmitted bool `json:"steeringEmitted"` + OccurredAt int64 `json:"occurredAt"` + ElapsedMs int64 `json:"elapsedMs"` + LastSequence int64 `json:"lastSequence"` + + State *SessionStateDto `json:"state"` + RiskSummary *RiskSummaryDto `json:"riskSummary"` + Status *ProviderHealthDto `json:"status"` + PendingAppr []ApprovalDto `json:"pendingApprovals"` + Tools []ToolRecordDto `json:"tools"` + RecentEvents []EventEntryDto `json:"recentEvents"` + Stages []StageToolDecl `json:"stages"` + Workflows []WorkflowDto `json:"workflows"` +} + +type SessionStateDto struct { + Status string `json:"status"` + CurrentStageID *string `json:"currentStageId"` + PauseReason *string `json:"pauseReason"` +} + +type RiskSummaryDto struct { + Level string `json:"level"` + Factors []string `json:"factors"` + RecommendedAction string `json:"recommendedAction"` +} + +type ProviderHealthDto struct { + ProviderID string `json:"providerId"` + Status string `json:"status"` + LoadPercent *int `json:"loadPercent"` +} + +type ApprovalDto struct { + RequestID string `json:"requestId"` + Tier string `json:"tier"` + ToolName *string `json:"toolName"` + Preview *string `json:"preview"` +} + +type StageToolDecl struct { + StageID string `json:"stageId"` + Tools []ToolDecl `json:"tools"` +} + +type ToolDecl struct { + Name string `json:"name"` + Tier int `json:"tier"` +} + +type EventEntryDto struct { + Timestamp int64 `json:"timestamp"` + Type string `json:"type"` + Detail string `json:"detail"` +} + +type ToolRecordDto struct { + Name string `json:"name"` + Tier int `json:"tier"` + Status string `json:"status"` + Diff *string `json:"diff"` +} + +type WorkflowDto struct { + WorkflowID string `json:"workflowId"` + Description string `json:"description"` +} + +// Decode parses one server frame into a ServerMessage. +func Decode(raw []byte) (ServerMessage, error) { + var m ServerMessage + err := json.Unmarshal(raw, &m) + return m, err +} + +// IsEventBearing reports whether a message carries a session event (and so must +// be buffered during the snapshot phase). Control/snapshot messages return false +// and are applied immediately. +func (m ServerMessage) IsEventBearing() bool { + switch m.Type { + case TypeSessionStarted, TypeSessionPaused, TypeSessionResumed, + TypeSessionCompleted, TypeSessionFailed, + TypeStageStarted, TypeStageCompleted, TypeStageFailed, + TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout, + TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected, + TypeApprovalRequired, TypeArtifactCreated: + return true + default: + return false + } +} + +// --- Client -> server encoders --- + +func encode(typeName string, fields map[string]any) []byte { + fields["type"] = clientPrefix + typeName + b, _ := json.Marshal(fields) + return b +} + +// StartChatSession opens a new chat session with a client-generated sessionId. +func StartChatSession(sessionID, text string) []byte { + return encode("StartChatSession", map[string]any{"sessionId": sessionID, "text": text}) +} + +// ChatInput sends a turn into an existing session. mode is "CHAT" or "STEERING". +func ChatInput(sessionID, text, mode string) []byte { + return encode("ChatInput", map[string]any{"sessionId": sessionID, "text": text, "mode": mode}) +} + +// StartSession launches a workflow-backed session. +func StartSession(workflowID string) []byte { + return encode("StartSession", map[string]any{"workflowId": workflowID, "config": nil}) +} + +// CancelSession cancels a running session. +func CancelSession(sessionID string) []byte { + return encode("CancelSession", map[string]any{"sessionId": sessionID}) +} + +// ApprovalResponse answers an approval gate. decision is "APPROVE" or "REJECT". +func ApprovalResponse(requestID, decision string, steeringNote *string) []byte { + return encode("ApprovalResponse", map[string]any{ + "requestId": requestID, + "decision": decision, + "steeringNote": steeringNote, + }) +} + +// Ping is a keepalive. +func Ping(ts int64) []byte { + return encode("Ping", map[string]any{"timestamp": ts}) +} + +// CreateGrant pre-authorizes a tool/tier so future calls skip the approval gate. +// scope is "SESSION" or "STAGE"; permittedTiers are tier names like "T3". +func CreateGrant(sessionID, scope string, permittedTiers []string, reason, toolName string) []byte { + return encode("CreateGrant", map[string]any{ + "sessionId": sessionID, + "scope": scope, + "stageId": nil, + "permittedTiers": permittedTiers, + "reason": reason, + "expiresAt": nil, + "toolName": toolName, + }) +} diff --git a/apps/tui-go/internal/ws/client.go b/apps/tui-go/internal/ws/client.go new file mode 100644 index 00000000..e08350d0 --- /dev/null +++ b/apps/tui-go/internal/ws/client.go @@ -0,0 +1,163 @@ +// Package ws is the WebSocket transport to apps/server. It owns a reconnecting +// connection and exposes decoded server messages plus connection-status changes +// over channels the Bubble Tea loop drains via commands. +package ws + +import ( + "context" + "net/url" + "time" + + "github.com/correx/tui-go/internal/protocol" + "github.com/gorilla/websocket" +) + +const ( + initialBackoff = 500 * time.Millisecond + maxBackoff = 8 * time.Second +) + +// Status reports connection lifecycle transitions. +type Status struct { + Connected bool + Reconnecting bool + Attempt int +} + +// Client is a reconnecting WebSocket client. Construct with New, then Run in a +// goroutine. Incoming and Conn are drained by the UI. +type Client struct { + url string + send chan []byte + in chan protocol.ServerMessage + conn chan Status +} + +// New builds a client targeting ws://host:port/stream. +func New(host string, port int) *Client { + u := url.URL{Scheme: "ws", Host: hostPort(host, port), Path: "/stream"} + return &Client{ + url: u.String(), + send: make(chan []byte, 64), + in: make(chan protocol.ServerMessage, 256), + conn: make(chan Status, 16), + } +} + +func hostPort(host string, port int) string { + h := host + if h == "" { + h = "localhost" + } + return h + ":" + itoa(port) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +// Incoming yields decoded server messages. +func (c *Client) Incoming() <-chan protocol.ServerMessage { return c.in } + +// Conn yields connection-status changes. +func (c *Client) Conn() <-chan Status { return c.conn } + +// Send queues a raw client frame. Non-blocking; drops if the buffer is full +// (the server will be resynced on reconnect via snapshot). +func (c *Client) Send(frame []byte) { + select { + case c.send <- frame: + default: + } +} + +// Run drives the reconnect loop until ctx is cancelled. +func (c *Client) Run(ctx context.Context) { + backoff := initialBackoff + attempt := 0 + for { + if ctx.Err() != nil { + return + } + conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.url, nil) + if err != nil { + attempt++ + c.emit(Status{Reconnecting: true, Attempt: attempt}) + if !sleep(ctx, backoff) { + return + } + backoff = min(backoff*2, maxBackoff) + continue + } + backoff = initialBackoff + attempt = 0 + c.emit(Status{Connected: true}) + c.pump(ctx, conn) + c.emit(Status{Reconnecting: true}) + } +} + +// pump runs the read/write loops for a single live connection and returns when +// it drops. +func (c *Client) pump(ctx context.Context, conn *websocket.Conn) { + connCtx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + for { + select { + case <-connCtx.Done(): + return + case frame := <-c.send: + if err := conn.WriteMessage(websocket.TextMessage, frame); err != nil { + cancel() + return + } + } + } + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + cancel() + _ = conn.Close() + return + } + if msg, derr := protocol.Decode(raw); derr == nil { + select { + case c.in <- msg: + case <-connCtx.Done(): + return + } + } + } +} + +func (c *Client) emit(s Status) { + select { + case c.conn <- s: + default: + } +} + +func sleep(ctx context.Context, d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} diff --git a/apps/tui-go/main.go b/apps/tui-go/main.go new file mode 100644 index 00000000..19411999 --- /dev/null +++ b/apps/tui-go/main.go @@ -0,0 +1,31 @@ +// Command tui is the correx terminal UI: a Bubble Tea client that speaks the +// correx WebSocket protocol to apps/server. +package main + +import ( + "context" + "flag" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/correx/tui-go/internal/app" + "github.com/correx/tui-go/internal/ws" +) + +func main() { + host := flag.String("host", "localhost", "server host") + port := flag.Int("port", 8080, "server port") + flag.Parse() + + client := ws.New(*host, *port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go client.Run(ctx) + + p := tea.NewProgram(app.NewModel(client), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "correx tui error:", err) + os.Exit(1) + } +}