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:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user