Files
2026-07-13 13:33:09 +08:00

764 lines
17 KiB
Go

package streamtui
import (
"context"
"fmt"
"log"
"os"
"plandex-cli/api"
"plandex-cli/lib"
"plandex-cli/term"
"strings"
"time"
shared "plandex-shared"
bubbleKey "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/davecgh/go-spew/spew"
"github.com/fatih/color"
)
func (m streamUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// log.Println("Stream TUI - Update received message:", spew.Sdump(msg))
switch msg := msg.(type) {
case spinner.TickMsg:
state := m.readState()
if state.processing || state.starting {
m.updateState(func() {
spinnerModel, _ := m.spinner.Update(msg)
m.spinner = spinnerModel
})
}
if state.building {
m.updateState(func() {
buildSpinnerModel, _ := m.buildSpinner.Update(msg)
m.buildSpinner = buildSpinnerModel
})
}
return m, m.Tick()
case tea.WindowSizeMsg:
m.windowResized(msg.Width, msg.Height)
case shared.StreamMessage:
return m.streamUpdate(&msg, false)
case contextLoadDoneMsg:
if msg.err != nil {
log.Println("failed to auto load context files:", msg.err)
m.updateState(func() {
m.err = msg.err
m.processing = false
})
return m, tea.Quit
}
// We have the loaded content in msg.text
m.updateState(func() {
if msg.text != "" {
m.reply += "\n\n" + msg.text + "\n\n"
}
// and keep processing
m.processing = true
})
m.updateReplyDisplay()
return m, m.Tick()
case delayFileRestartMsg:
m.updateState(func() {
m.finishedByPath[msg.path] = false
})
// Scroll wheel doesn't seem to work--not sure why
// case tea.MouseMsg:
// if !m.promptingMissingFile {
// if msg.Type == tea.MouseWheelUp {
// m.mainViewport.LineUp(3)
// } else if msg.Type == tea.MouseWheelDown {
// m.mainViewport.LineDown(3)
// }
// }
case tea.KeyMsg:
switch {
case bubbleKey.Matches(msg, m.keymap.stop) || bubbleKey.Matches(msg, m.keymap.quit):
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
apiErr := api.Client.StopPlan(ctx, lib.CurrentPlanId, lib.CurrentBranch)
if apiErr != nil {
log.Println("stop plan api error:", apiErr)
m.updateState(func() {
m.apiErr = apiErr
})
}
m.updateState(func() {
m.stopped = true
})
return m, tea.Quit
case bubbleKey.Matches(msg, m.keymap.background):
state := m.readState()
if state.canSendToBg {
m.updateState(func() {
m.background = true
})
return m, tea.Quit
}
case bubbleKey.Matches(msg, m.keymap.scrollDown) && !m.promptingMissingFile:
m.scrollDown()
case bubbleKey.Matches(msg, m.keymap.scrollUp) && !m.promptingMissingFile:
m.scrollUp()
case bubbleKey.Matches(msg, m.keymap.pageDown) && !m.promptingMissingFile:
m.pageDown()
case bubbleKey.Matches(msg, m.keymap.pageUp) && !m.promptingMissingFile:
m.pageUp()
case bubbleKey.Matches(msg, m.keymap.up) && m.building:
m.up()
case bubbleKey.Matches(msg, m.keymap.down) && m.building:
m.down()
case bubbleKey.Matches(msg, m.keymap.start) && !m.promptingMissingFile:
m.scrollStart()
case bubbleKey.Matches(msg, m.keymap.end) && !m.promptingMissingFile:
m.scrollEnd()
case m.promptingMissingFile && bubbleKey.Matches(msg, m.keymap.enter):
return m.selectedMissingFileOpt()
default:
m.resolveEscapeSequence(msg.String())
}
case buildStatusPollMsg:
state := m.readState()
numPaths := len(m.tokensByPath)
numFinished := 0
for _, isBuilt := range m.finishedByPath {
if isBuilt {
numFinished++
}
}
if !state.finished && !state.stopped && !state.background && numPaths > 0 && numPaths != numFinished {
status, apiErr := api.Client.GetBuildStatus(lib.CurrentPlanId, lib.CurrentBranch)
if apiErr != nil {
return m, m.pollBuildStatus()
}
m.updateState(func() {
for path, isBuilt := range status.BuiltFiles {
isBuilding := status.IsBuildingByPath[path]
if isBuilt && !isBuilding {
m.finishedByPath[path] = true
}
}
})
}
return m, m.pollBuildStatus()
}
return m, nil
}
func (m *streamUIModel) windowResized(w, h int) {
m.updateState(func() {
m.width = w
m.height = h
})
state := m.readState()
_, viewportHeight := state.getViewportDimensions()
if state.ready {
m.updateViewportDimensions()
} else {
m.updateState(func() {
m.mainViewport = viewport.New(w, viewportHeight)
m.mainViewport.Style = lipgloss.NewStyle().Padding(0, 1, 0, 1)
})
m.updateReplyDisplay()
m.updateState(func() {
m.ready = true
})
}
}
func (m *streamUIModel) updateReplyDisplay() {
state := m.readState()
if state.buildOnly {
return
}
s := ""
if state.prompt != "" {
promptTxt := term.GetPlain(state.prompt)
s += color.New(color.BgGreen, color.Bold, color.FgHiWhite).Sprintf(" 💬 User prompt 👇 ")
s += "\n\n" + strings.TrimSpace(promptTxt) + "\n"
}
if state.reply != "" {
replyMd, _ := term.GetMarkdown(state.reply)
s += "\n" + color.New(color.BgBlue, color.Bold, color.FgHiWhite).Sprintf(" 🤖 Plandex reply 👇 ")
s += "\n\n" + strings.TrimSpace(replyMd)
} else {
s += "\n"
}
m.updateState(func() {
m.mainDisplay = s
m.mainViewport.SetContent(s)
})
m.updateViewportDimensions()
if state.atScrollBottom {
m.updateState(func() {
m.mainViewport.GotoBottom()
})
}
}
func (m *streamUIModel) updateViewportDimensions() {
state := m.readState()
w, h := state.getViewportDimensions()
m.updateState(func() {
m.mainViewport.Width = w
m.mainViewport.Height = h
})
}
func (m *streamUIModel) getViewportDimensions() (int, int) {
w := m.width
h := m.height
helpHeight := lipgloss.Height(m.renderHelp())
var buildHeight int
if m.building {
if m.buildViewCollapsed {
buildHeight = 3
} else {
buildHeight = len(m.getRows(false))
}
}
var processingHeight int
if m.starting || m.processing {
processingHeight = lipgloss.Height(m.renderProcessing())
}
maxViewportHeight := h - (helpHeight + processingHeight + buildHeight)
if maxViewportHeight < 0 {
maxViewportHeight = 0
}
viewportHeight := min(maxViewportHeight, lipgloss.Height(m.mainDisplay))
viewportWidth := w
return viewportWidth, viewportHeight
}
func (m streamUIModel) replyScrollable() bool {
return m.mainViewport.TotalLineCount() > m.mainViewport.VisibleLineCount()
}
func (m *streamUIModel) scrollDown() {
state := m.readState()
if state.replyScrollable() {
m.updateState(func() {
m.mainViewport.LineDown(1)
})
}
state = m.readState()
m.updateState(func() {
m.atScrollBottom = !state.replyScrollable() || state.mainViewport.AtBottom()
})
}
func (m *streamUIModel) scrollUp() {
state := m.readState()
if state.replyScrollable() {
m.updateState(func() {
m.mainViewport.LineUp(1)
m.atScrollBottom = false
})
}
}
func (m *streamUIModel) pageDown() {
state := m.readState()
if state.replyScrollable() {
m.updateState(func() {
m.mainViewport.ViewDown()
})
}
state = m.readState()
m.updateState(func() {
m.atScrollBottom = !state.replyScrollable() || state.mainViewport.AtBottom()
})
}
func (m *streamUIModel) pageUp() {
state := m.readState()
if state.replyScrollable() {
m.updateState(func() {
m.mainViewport.ViewUp()
m.atScrollBottom = false
})
}
}
func (m *streamUIModel) scrollStart() {
state := m.readState()
if state.replyScrollable() {
m.updateState(func() {
m.mainViewport.GotoTop()
m.atScrollBottom = false
})
}
}
func (m *streamUIModel) scrollEnd() {
state := m.readState()
if state.replyScrollable() {
m.updateState(func() {
m.mainViewport.GotoBottom()
m.atScrollBottom = true
})
}
}
func (m *streamUIModel) streamUpdate(msg *shared.StreamMessage, deferUIUpdate bool) (tea.Model, tea.Cmd) {
switch msg.Type {
case shared.StreamMessageMulti:
cmds := []tea.Cmd{}
for _, subMsg := range msg.StreamMessages {
teaModel, cmd := m.streamUpdate(&subMsg, true)
m = teaModel.(*streamUIModel)
if cmd != nil {
cmds = append(cmds, cmd)
}
}
m.updateReplyDisplay()
m.updateViewportDimensions()
return m, tea.Batch(cmds...)
case shared.StreamMessageConnectActive:
if msg.InitPrompt != "" {
m.updateState(func() {
m.prompt = msg.InitPrompt
})
}
if msg.InitBuildOnly {
m.updateState(func() {
m.buildOnly = true
})
}
if len(msg.InitReplies) > 0 {
m.updateState(func() {
m.reply = strings.Join(msg.InitReplies, "\n\n👇\n\n")
})
}
m.updateReplyDisplay()
return m.checkMissingFile(msg)
case shared.StreamMessagePromptMissingFile:
return m.checkMissingFile(msg)
case shared.StreamMessageReply:
// ignore empty reply messages
if msg.ReplyChunk == "" {
return m, nil
}
state := m.readState()
if state.starting {
m.updateState(func() {
m.starting = false
})
}
if state.processing {
log.Println("Non-empty message reply, setting processing to false")
m.updateState(func() {
m.processing = false
if state.promptedMissingFile || state.autoLoadedMissingFile {
log.Println("Prompted missing file or auto loaded missing file, resetting (and skipping 👇 marker)")
m.promptedMissingFile = false
m.autoLoadedMissingFile = false
} else {
log.Println("Not prompted missing file or auto loaded missing file, adding 👇 marker")
m.reply += "\n\n👇\n\n"
}
})
}
m.updateState(func() {
m.reply += msg.ReplyChunk
})
if !deferUIUpdate {
m.updateReplyDisplay()
}
case shared.StreamMessageBuildInfo:
state := m.readState()
if state.starting {
m.updateState(func() {
m.starting = false
})
}
m.updateState(func() {
m.building = true
})
wasFinished := state.finishedByPath[msg.BuildInfo.Path]
nowFinished := msg.BuildInfo.Finished
m.updateState(func() {
if msg.BuildInfo.Removed {
m.removedByPath[msg.BuildInfo.Path] = true
} else {
m.removedByPath[msg.BuildInfo.Path] = false
}
})
if msg.BuildInfo.Finished {
m.updateState(func() {
m.tokensByPath[msg.BuildInfo.Path] = 0
m.finishedByPath[msg.BuildInfo.Path] = true
})
} else {
if wasFinished && !nowFinished {
// delay for a second before marking not finished again (so check flashes green prior to restarting build)
log.Println("Stream message build info - delaying for 1 second before marking not finished again")
return m, startDelay(msg.BuildInfo.Path, time.Second*1)
} else {
m.updateState(func() {
m.finishedByPath[msg.BuildInfo.Path] = false
})
}
m.updateState(func() {
m.tokensByPath[msg.BuildInfo.Path] += msg.BuildInfo.NumTokens
})
}
// Auto-collapse if build info takes up too much space
state = m.readState()
if !state.userToggledBuild && state.building {
rows := len(m.getRows(false))
m.updateState(func() {
m.buildViewCollapsed = rows > 3
})
}
if !deferUIUpdate {
m.updateViewportDimensions()
}
return m, m.Tick()
case shared.StreamMessageDescribing:
log.Println("Message describing, setting processing to true")
m.updateState(func() {
m.processing = true
})
return m, m.Tick()
// Instead of blocking here, we'll spawn a command
case shared.StreamMessageLoadContext:
m.updateState(func() {
m.processing = true
})
return m, tea.Batch(
loadContextCmd(msg.LoadContextFiles),
tea.Tick(time.Second/10, func(t time.Time) tea.Msg {
return spinner.TickMsg{}
}),
)
case shared.StreamMessageError:
log.Println("Stream message error:", spew.Sdump(msg))
state := m.readState()
if state.autoLoadContextCancelFn != nil {
state.autoLoadContextCancelFn()
}
m.updateState(func() {
m.apiErr = msg.Error
})
return m, tea.Quit
case shared.StreamMessageFinished:
m.updateState(func() {
m.finished = true
})
return m, tea.Quit
case shared.StreamMessageAborted:
m.updateState(func() {
m.stopped = true
})
return m, tea.Quit
case shared.StreamMessageRepliesFinished:
log.Println("Replies finished, setting processing to false")
state := m.readState()
m.updateState(func() {
m.processing = false
})
if state.building {
return m, m.Tick()
}
}
return m, nil
}
type delayFileRestartMsg struct {
path string
}
func startDelay(path string, delay time.Duration) tea.Cmd {
return func() tea.Msg {
time.Sleep(delay)
return delayFileRestartMsg{path: path}
}
}
var escReceivedAt time.Time
var escSeq string
func (m *streamUIModel) resolveEscapeSequence(val string) {
if val == "esc" || val == "alt+[" {
escReceivedAt = time.Now()
go func() {
time.Sleep(51 * time.Millisecond)
escReceivedAt = time.Time{}
escSeq = ""
}()
}
if !escReceivedAt.IsZero() {
elapsed := time.Since(escReceivedAt)
if elapsed < 50*time.Millisecond {
escSeq += val
if escSeq == "esc[A" || escSeq == "alt+[A" {
m.up()
escReceivedAt = time.Time{}
escSeq = ""
} else if escSeq == "esc[B" || escSeq == "alt+[B" {
m.down()
escReceivedAt = time.Time{}
escSeq = ""
}
}
}
}
func (m *streamUIModel) up() {
state := m.readState()
if state.promptingMissingFile {
m.updateState(func() {
m.missingFileSelectedIdx = max(m.missingFileSelectedIdx-1, 0)
})
} else {
m.updateState(func() {
m.buildViewCollapsed = false
m.userToggledBuild = true
})
}
}
func (m *streamUIModel) down() {
state := m.readState()
if state.promptingMissingFile {
m.updateState(func() {
m.missingFileSelectedIdx = min(m.missingFileSelectedIdx+1, len(missingFileSelectOpts)-1)
})
} else {
m.updateState(func() {
m.buildViewCollapsed = true
m.userToggledBuild = true
})
}
}
func (m *streamUIModel) selectedMissingFileOpt() (tea.Model, tea.Cmd) {
state := m.readState()
choice := promptChoices[state.missingFileSelectedIdx]
if choice == "" {
return m, nil
}
apiErr := api.Client.RespondMissingFile(lib.CurrentPlanId, lib.CurrentBranch, shared.RespondMissingFileRequest{
Choice: choice,
FilePath: m.missingFilePath,
Body: m.missingFileContent,
})
if apiErr != nil {
log.Println("missing file prompt api error:", apiErr)
m.updateState(func() {
m.apiErr = apiErr
})
return m, nil
}
if choice == shared.RespondMissingFileChoiceSkip {
replyLines := strings.Split(state.reply, "\n")
m.updateState(func() {
m.reply = strings.Join(replyLines[:len(replyLines)-3], "\n")
})
m.updateReplyDisplay()
}
m.updateState(func() {
m.promptingMissingFile = false
m.missingFilePath = ""
m.missingFileSelectedIdx = 0
m.missingFileContent = ""
m.missingFileTokens = 0
m.promptedMissingFile = true
m.processing = true
})
return m, func() tea.Msg {
<-m.sharedTicker.C
return spinner.TickMsg{}
}
}
func (m *streamUIModel) checkMissingFile(msg *shared.StreamMessage) (tea.Model, tea.Cmd) {
if msg.MissingFilePath != "" {
log.Println("checkMissingFile - received missing file message | path:", msg.MissingFilePath)
if msg.MissingFileAutoContext {
log.Println("checkMissingFile - received missing file message | auto context")
m.updateState(func() {
m.processing = true
m.autoLoadedMissingFile = true
})
return m, tea.Batch(
func() tea.Msg {
<-m.sharedTicker.C
return spinner.TickMsg{}
},
func() tea.Msg {
bytes, err := os.ReadFile(msg.MissingFilePath)
if err != nil {
log.Println("failed to read file:", err)
m.err = fmt.Errorf("failed to read file: %w", err)
return tea.Quit
}
content := string(shared.NormalizeEOL(bytes))
log.Println("checkMissingFile - calling RespondMissingFile")
apiErr := api.Client.RespondMissingFile(lib.CurrentPlanId, lib.CurrentBranch, shared.RespondMissingFileRequest{
Choice: shared.RespondMissingFileChoiceLoad,
FilePath: msg.MissingFilePath,
Body: content,
})
if apiErr != nil {
log.Println("missing file prompt api error:", apiErr)
m.updateState(func() {
m.apiErr = apiErr
})
return tea.Quit
}
log.Println("checkMissingFile - RespondMissingFile success")
return nil
},
)
}
m.updateState(func() {
m.promptingMissingFile = true
m.missingFilePath = msg.MissingFilePath
})
bytes, err := os.ReadFile(m.missingFilePath)
if err != nil {
log.Println("failed to read file:", err)
m.updateState(func() {
m.err = fmt.Errorf("failed to read file: %w", err)
})
return m, nil
}
missingFileContent := string(bytes)
m.updateState(func() {
m.missingFileContent = missingFileContent
})
numTokens := shared.GetNumTokensEstimate(missingFileContent)
m.updateState(func() {
m.missingFileTokens = numTokens
})
}
return m, nil
}
// contextLoadDoneMsg is sent when the long-running AutoLoadContextFiles completes
type contextLoadDoneMsg struct {
text string
err error
}
func loadContextCmd(loadContextFiles []string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Run the long operation directly
text, err := lib.AutoLoadContextFiles(ctx, loadContextFiles)
// Return the result as a message
return contextLoadDoneMsg{
text: text,
err: err,
}
}
}