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:
+3
-1
@@ -71,4 +71,6 @@ bin/
|
|||||||
log4j2.properties
|
log4j2.properties
|
||||||
/docs/specs/
|
/docs/specs/
|
||||||
correx-tui.log
|
correx-tui.log
|
||||||
current.log
|
current.log
|
||||||
|
# Go TUI build output
|
||||||
|
apps/tui-go/bin/
|
||||||
|
|||||||
@@ -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