Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 11 additions & 129 deletions cmd/chat_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,105 +137,23 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

switch msg := msg.(type) {
case sessionSaveResultMsg:
if msg.err != nil {
m.recordWALError(msg.err)
return m, nil
}
// Remove the WAL only when no append happened after the snapshot.
// Doing this in the background save closure could race with a new
// submission and lose messages that were not in the saved session.
if msg.id == m.sessionID && msg.seq == m.walSeq && m.wal != nil {
if err := m.wal.Remove(); err != nil {
m.recordWALError(err)
} else {
m.wal = nil
}
}
return m, nil
return m.updateSessionSaveResult(msg)
case tea.FocusMsg:
m.viewDirty = true
m.updateViewportContent()
if focus := m.ensurePromptInputFocus(); focus != nil {
return m, focus
}
return m, nil

return m.updateFocusMsg(msg)
case tea.BlurMsg:
if m.uiFocus == focusPrompt && !m.configOpen && !m.useConfigInput {
m.input.Blur()
}
// Track that the terminal lost focus during a turn so we can notify
// the user when the agent completes.
if m.waiting {
m.backgrounded = true
}
m.viewDirty = true
m.updateViewportContent()
return m, nil

return m.updateBlurMsg(msg)
case tea.InterruptMsg:
// External SIGINT delivered while the terminal is not in raw mode
// (e.g. `kill -INT`, tmux/screen `prefix` + ctrl+c). Bubble Tea would
// otherwise exit without saving the session.
return m.quitModel()

return m.updateInterruptMsg(msg)
case tea.QuitMsg:
// SIGTERM (e.g. `kill <pid>`, terminal close on some platforms).
// Exit through the same save-and-cleanup path as Ctrl+C.
return m.quitModel()

return m.updateQuitMsg(msg)
case promptKeepAliveMsg:
if m.uiFocus == focusPrompt && !m.configOpen && !m.useConfigInput {
if !m.input.Focused() {
m.viewDirty = true
m.updateViewportContent()
return m, tea.Batch(promptKeepAliveCmd(), m.input.Focus())
}
}
return m, promptKeepAliveCmd()

return m.updatePromptKeepAliveMsg(msg)
case statusLeftPRsMsg:
// Async PR lookup result — only apply if we're still on the same branch.
if m.statusLeftBranch == msg.branch {
m.statusLeftPRs = msg.nums
m.viewDirty = true
m.updateViewportContent()
}
return m, nil

return m.updateStatusLeftPRsMsg(msg)
case eyeBlinkTickMsg:
if m.showWelcomeBanner() {
m.eyeFrame = 1
m.rebuildWelcomeCache()
if len(m.messages) > 0 && m.messages[0].role == "welcome" {
m.messages[0].content = m.welcomeCache
}
m.viewDirty = true
m.updateViewportContent()
return m, tea.Batch(eyeBlinkTickCmd(), eyeFrameNextCmd(2, 60*time.Millisecond))
}
return m, eyeBlinkTickCmd()

return m.updateEyeBlinkTickMsg(msg)
case eyeFrameNextMsg:
if m.showWelcomeBanner() {
m.eyeFrame = msg.frame
m.rebuildWelcomeCache()
if len(m.messages) > 0 && m.messages[0].role == "welcome" {
m.messages[0].content = m.welcomeCache
}
m.viewDirty = true
m.updateViewportContent()
switch msg.frame {
case 2:
return m, eyeFrameNextCmd(3, 100*time.Millisecond)
case 3:
return m, eyeFrameNextCmd(0, 60*time.Millisecond)
}
} else {
m.eyeFrame = 0
}
return m, nil

return m.updateEyeFrameNextMsg(msg)
case tea.MouseMsg:
if m.mouseEnabled() {
if m.configOpen {
Expand Down Expand Up @@ -264,45 +182,9 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)

case processArrowTickMsg:
// A matching seq means no newer arrow keypress has arrived since this
// tick was armed, so the burst is over — clear the flag unconditionally.
// Without this, a burst's final keypress (which arms no tick of its
// own) would leave arrowBurstActive stuck true forever, silently
// swallowing every subsequent keystroke (see applyPromptArrowKey).
if m.arrowSeq == msg.seq {
m.arrowBurstActive = false
}
if m.pendingArrow != nil && m.arrowSeq == msg.seq {
msgToProcess := *m.pendingArrow
m.pendingArrow = nil
m.arrowBurstActive = false
m.processingGenuineArrow = true
next, cmd := m.Update(msgToProcess)
if nextModel, ok := next.(chatModel); ok {
nextModel.processingGenuineArrow = false
return nextModel, cmd
}
return next, cmd
}
return m, nil

return m.updateProcessArrowTickMsg(msg)
case tea.PasteMsg:
if m.configOpen {
var cmd tea.Cmd
m.configInput, cmd = m.configInput.Update(msg)
m.viewDirty = true
m.updateViewportContent()
return m, cmd
}
if m.uiFocus == focusPrompt {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.viewDirty = true
m.updateViewportContent()
return m, cmd
}
return m, nil

return m.updatePasteMsg(msg)
case tea.KeyMsg:
s := msg.String()
if (s == "up" || s == "down") && !m.processingGenuineArrow {
Expand Down
163 changes: 163 additions & 0 deletions cmd/chat_update_system.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package cmd

import (
"time"

tea "charm.land/bubbletea/v2"
)

// System-message handlers extracted from chatModel.Update. Each
// handles one terminal message type (every path returns); the dispatch
// switch in Update keeps only the one-line call.

func (m chatModel) updateSessionSaveResult(msg sessionSaveResultMsg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.recordWALError(msg.err)
return m, nil
}
// Remove the WAL only when no append happened after the snapshot.
// Doing this in the background save closure could race with a new
// submission and lose messages that were not in the saved session.
if msg.id == m.sessionID && msg.seq == m.walSeq && m.wal != nil {
if err := m.wal.Remove(); err != nil {
m.recordWALError(err)
} else {
m.wal = nil
}
}
return m, nil
}

func (m chatModel) updateFocusMsg(msg tea.FocusMsg) (tea.Model, tea.Cmd) {
m.viewDirty = true
m.updateViewportContent()
if focus := m.ensurePromptInputFocus(); focus != nil {
return m, focus
}
return m, nil
}

func (m chatModel) updateBlurMsg(msg tea.BlurMsg) (tea.Model, tea.Cmd) {
if m.uiFocus == focusPrompt && !m.configOpen && !m.useConfigInput {
m.input.Blur()
}
// Track that the terminal lost focus during a turn so we can notify
// the user when the agent completes.
if m.waiting {
m.backgrounded = true
}
m.viewDirty = true
m.updateViewportContent()
return m, nil
}

func (m chatModel) updateInterruptMsg(msg tea.InterruptMsg) (tea.Model, tea.Cmd) {
// External SIGINT delivered while the terminal is not in raw mode
// (e.g. `kill -INT`, tmux/screen `prefix` + ctrl+c). Bubble Tea would
// otherwise exit without saving the session.
return m.quitModel()
}

func (m chatModel) updateQuitMsg(msg tea.QuitMsg) (tea.Model, tea.Cmd) {
// SIGTERM (e.g. `kill <pid>`, terminal close on some platforms).
// Exit through the same save-and-cleanup path as Ctrl+C.
return m.quitModel()
}

func (m chatModel) updatePromptKeepAliveMsg(msg promptKeepAliveMsg) (tea.Model, tea.Cmd) {
if m.uiFocus == focusPrompt && !m.configOpen && !m.useConfigInput {
if !m.input.Focused() {
m.viewDirty = true
m.updateViewportContent()
return m, tea.Batch(promptKeepAliveCmd(), m.input.Focus())
}
}
return m, promptKeepAliveCmd()
}

func (m chatModel) updateStatusLeftPRsMsg(msg statusLeftPRsMsg) (tea.Model, tea.Cmd) {
// Async PR lookup result — only apply if we're still on the same branch.
if m.statusLeftBranch == msg.branch {
m.statusLeftPRs = msg.nums
m.viewDirty = true
m.updateViewportContent()
}
return m, nil
}

func (m chatModel) updateEyeBlinkTickMsg(msg eyeBlinkTickMsg) (tea.Model, tea.Cmd) {
if m.showWelcomeBanner() {
m.eyeFrame = 1
m.rebuildWelcomeCache()
if len(m.messages) > 0 && m.messages[0].role == "welcome" {
m.messages[0].content = m.welcomeCache
}
m.viewDirty = true
m.updateViewportContent()
return m, tea.Batch(eyeBlinkTickCmd(), eyeFrameNextCmd(2, 60*time.Millisecond))
}
return m, eyeBlinkTickCmd()
}

func (m chatModel) updateEyeFrameNextMsg(msg eyeFrameNextMsg) (tea.Model, tea.Cmd) {
if m.showWelcomeBanner() {
m.eyeFrame = msg.frame
m.rebuildWelcomeCache()
if len(m.messages) > 0 && m.messages[0].role == "welcome" {
m.messages[0].content = m.welcomeCache
}
m.viewDirty = true
m.updateViewportContent()
switch msg.frame {
case 2:
return m, eyeFrameNextCmd(3, 100*time.Millisecond)
case 3:
return m, eyeFrameNextCmd(0, 60*time.Millisecond)
}
} else {
m.eyeFrame = 0
}
return m, nil
}

func (m chatModel) updateProcessArrowTickMsg(msg processArrowTickMsg) (tea.Model, tea.Cmd) {
// A matching seq means no newer arrow keypress has arrived since this
// tick was armed, so the burst is over — clear the flag unconditionally.
// Without this, a burst's final keypress (which arms no tick of its
// own) would leave arrowBurstActive stuck true forever, silently
// swallowing every subsequent keystroke (see applyPromptArrowKey).
if m.arrowSeq == msg.seq {
m.arrowBurstActive = false
}
if m.pendingArrow != nil && m.arrowSeq == msg.seq {
msgToProcess := *m.pendingArrow
m.pendingArrow = nil
m.arrowBurstActive = false
m.processingGenuineArrow = true
next, cmd := m.Update(msgToProcess)
if nextModel, ok := next.(chatModel); ok {
nextModel.processingGenuineArrow = false
return nextModel, cmd
}
return next, cmd
}
return m, nil
}

func (m chatModel) updatePasteMsg(msg tea.PasteMsg) (tea.Model, tea.Cmd) {
if m.configOpen {
var cmd tea.Cmd
m.configInput, cmd = m.configInput.Update(msg)
m.viewDirty = true
m.updateViewportContent()
return m, cmd
}
if m.uiFocus == focusPrompt {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.viewDirty = true
m.updateViewportContent()
return m, cmd
}
return m, nil
}
15 changes: 15 additions & 0 deletions cmd/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

rhoconfig "github.com/GrayCodeAI/rho/internal/config"
"github.com/GrayCodeAI/rho/internal/plugin"
"github.com/GrayCodeAI/rho/internal/resilience/health"
"github.com/GrayCodeAI/rho/internal/session"
"github.com/GrayCodeAI/rho/internal/storage"
Expand Down Expand Up @@ -98,6 +99,20 @@ func doctorReport(settings rhoconfig.Settings) string {
} else {
b.WriteString("Installed skills: none (install with `rho skills install`)\n")
}
// Warn when installed skills drifted from the lockfile pins (content
// changed or SKILL.md removed after install). Warn-only: local edits are
// legitimate, they just mean the install is no longer reproducible.
if drift := plugin.VerifyInstalledSkills("user"); len(drift) > 0 {
b.WriteString("Skills lock drift:\n")
const maxDriftLines = 5
for i, line := range drift {
if i >= maxDriftLines {
b.WriteString(fmt.Sprintf(" - ... and %d more (reinstall to re-pin)\n", len(drift)-maxDriftLines))
break
}
b.WriteString(" - " + line + "\n")
}
}

b.WriteString(fmt.Sprintf("Configured MCP servers: %d\n", len(settings.MCPServers)+len(mcpServers)))
b.WriteString(fmt.Sprintf("Built-in tools: %d\n", len(allTools())))
Expand Down
Loading
Loading