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.
This commit is contained in:
2026-05-30 00:00:35 +04:00
parent 1f6680f774
commit f922b855eb
14 changed files with 2878 additions and 1 deletions
+30
View File
@@ -0,0 +1,30 @@
// Command preview prints a single static TUI frame for screenshotting.
// Usage: preview <kind> [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))
}
+32
View File
@@ -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
)
+50
View File
@@ -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=
+144
View File
@@ -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"},
}
}
+242
View File
@@ -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
}
+286
View File
@@ -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]) + "…"
}
+331
View File
@@ -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) }
+142
View File
@@ -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
}
}
+665
View File
@@ -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
}
+548
View File
@@ -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: <session/none> · <mode>
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
}
+211
View File
@@ -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,
})
}
+163
View File
@@ -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
}
}
+31
View File
@@ -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)
}
}