Skip to content
Open
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
134 changes: 111 additions & 23 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ type AppModel struct {
height int
showInspect bool

inspectCache map[string]inspectCacheEntry
inspectInFlight map[string]struct{}

activeView appView
activeSessionID string
sessionManager terminalSessionManager
Expand All @@ -85,8 +88,21 @@ type AppModel struct {
const (
errorToastTimeout = 5 * time.Second
maxSanitizedErrorRunes = 320
inspectCacheTTL = 30 * time.Second
inspectErrorCacheTTL = 2 * time.Second
inspectFetchTimeout = 30 * time.Second
)

type inspectCacheEntry struct {
Content string
Err string
FetchedAt time.Time
}

type inspectDetailProber interface {
FetchSessionInspectLatestBlock(ctx context.Context, host model.Host, session model.Session) (string, error)
}

// NewApp constructs the root model with injected services.
func NewApp(cfg config.Config, discoverer Discoverer, proberSvc Prober, logger *slog.Logger) *AppModel {
if logger == nil {
Expand All @@ -108,22 +124,24 @@ func NewApp(cfg config.Config, discoverer Discoverer, proberSvc Prober, logger *
}

app := &AppModel{
cfg: cfg,
theme: th,
keys: keyMap,
logger: appLogger,
discovery: discoverer,
prober: proberSvc,
header: components.NewHeaderBar(th, cfg.Polling.Interval),
tree: components.NewSessionTreeView(th),
inspect: components.NewInspectPanel(th),
footer: components.NewFooterHelpBar(keyMap, th),
toast: components.NewInlineToast(th),
modal: components.NewModalLayer(th),
spinner: components.NewBrailleSpinner(cfg.Display.Animation),
showInspect: true,
activeView: viewTree,
sessionManager: session.NewManager(nil, logger, buildSSHControlOpts(cfg.SSH)),
cfg: cfg,
theme: th,
keys: keyMap,
logger: appLogger,
discovery: discoverer,
prober: proberSvc,
header: components.NewHeaderBar(th, cfg.Polling.Interval),
tree: components.NewSessionTreeView(th),
inspect: components.NewInspectPanel(th),
footer: components.NewFooterHelpBar(keyMap, th),
toast: components.NewInlineToast(th),
modal: components.NewModalLayer(th),
spinner: components.NewBrailleSpinner(cfg.Display.Animation),
showInspect: true,
inspectCache: make(map[string]inspectCacheEntry),
inspectInFlight: make(map[string]struct{}),
activeView: viewTree,
sessionManager: session.NewManager(nil, logger, buildSSHControlOpts(cfg.SSH)),
}
app.tree.SetActiveSessionLookup(func(sessionID string) bool {
if strings.TrimSpace(sessionID) == "" {
Expand Down Expand Up @@ -207,6 +225,21 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, toastCmd)
}

case model.SessionInspectResultMsg:
if _, ok := m.inspectInFlight[typed.Key]; ok {
delete(m.inspectInFlight, typed.Key)
}

entry := inspectCacheEntry{Content: typed.Content, FetchedAt: time.Now()}
if typed.Err != nil {
entry.Err = sanitizeError(typed.Err)
}
m.inspectCache[typed.Key] = entry

if cmd := m.syncInspectSelection(); cmd != nil {
cmds = append(cmds, cmd)
}

case model.TerminalOutputMsg:
if typed.SessionID == m.activeSessionID {
m.logger.Debug("terminal output", "session_id", typed.SessionID, "bytes", len(typed.Data))
Expand Down Expand Up @@ -457,7 +490,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

m.tree.SetFilter(m.header.SearchQuery())
m.syncInspectSelection()
if cmd := m.syncInspectSelection(); cmd != nil {
cmds = append(cmds, cmd)
}
m.syncFooterContext()

return m, tea.Batch(cmds...)
Expand Down Expand Up @@ -584,22 +619,75 @@ func (m *AppModel) applyProbeResult(msg model.ProbeResultMsg) tea.Cmd {
}
if hasNonAuthErrors {
toastCmd := m.showErrorToast(msg.Err)
m.syncInspectSelection()
if inspectCmd := m.syncInspectSelection(); inspectCmd != nil {
return tea.Batch(toastCmd, inspectCmd)
}
return toastCmd
}
}

m.syncInspectSelection()
return nil
return m.syncInspectSelection()
}

func (m *AppModel) syncInspectSelection() {
func (m *AppModel) syncInspectSelection() tea.Cmd {
host, project, session, ok := m.tree.Selected()
if !ok || session == nil || project == nil || host == nil {
m.inspect.ClearSelection()
return
return nil
}

decorated := *session
key := inspectSelectionKey(*host, *project, decorated)

if entry, ok := m.inspectCache[key]; ok {
ttl := inspectCacheTTL
if strings.TrimSpace(entry.Err) != "" {
ttl = inspectErrorCacheTTL
}
if time.Since(entry.FetchedAt) <= ttl {
decorated.InspectLatestBlock = entry.Content
decorated.InspectError = entry.Err
} else {
delete(m.inspectCache, key)
}
}

if _, loading := m.inspectInFlight[key]; loading {
decorated.InspectLoading = true
} else if m.showInspect && strings.TrimSpace(decorated.InspectLatestBlock) == "" && strings.TrimSpace(decorated.InspectError) == "" {
decorated.InspectLoading = true
m.inspectInFlight[key] = struct{}{}
m.inspect.SetSelection(*host, *project, decorated)
return m.fetchInspectSelectionCmd(key, *host, decorated)
}

m.inspect.SetSelection(*host, *project, decorated)
return nil
}

func inspectSelectionKey(host model.Host, project model.Project, session model.Session) string {
return strings.Join([]string{host.Name, project.Name, session.ID, session.Directory}, "|")
}

func (m *AppModel) fetchInspectSelectionCmd(key string, host model.Host, session model.Session) tea.Cmd {
prober, ok := m.prober.(inspectDetailProber)
if !ok {
return func() tea.Msg {
return model.SessionInspectResultMsg{Key: key}
}
}

return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), inspectFetchTimeout)
defer cancel()

content, err := prober.FetchSessionInspectLatestBlock(ctx, host, session)
return model.SessionInspectResultMsg{
Key: key,
Content: content,
Err: err,
}
}
m.inspect.SetSelection(*host, *project, *session)
}

func (m *AppModel) resize(width, height int) {
Expand Down
17 changes: 16 additions & 1 deletion internal/tui/components/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,24 @@ func (p InspectPanel) View() string {
fmt.Sprintf("Messages: %d", p.session.MessageCount),
fmt.Sprintf("Agents: %s", nonEmpty(strings.Join(p.session.Agents, ", "), "(none)")),
"",
"Actions: Enter attach • n new session • d kill • g clone",
"Latest Conversation:",
}

if p.session.InspectLoading {
lines = append(lines, p.theme.TreeMuted.Render("Loading latest conversation..."))
} else if strings.TrimSpace(p.session.InspectError) != "" {
lines = append(lines, p.theme.TreeMuted.Render("Unavailable: "+p.session.InspectError))
} else if strings.TrimSpace(p.session.InspectLatestBlock) != "" {
lines = append(lines, p.session.InspectLatestBlock)
} else {
lines = append(lines, p.theme.TreeMuted.Render("No conversation block available"))
}

lines = append(lines,
"",
"Actions: Enter attach • n new session • d kill • g clone",
)

body := strings.Join(lines, "\n")
if p.width > 0 {
body = lipgloss.NewStyle().Width(maxInt(0, p.width-2)).Render(body)
Expand Down
6 changes: 6 additions & 0 deletions internal/tui/model/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ type ProbeResultMsg struct {
RefreshedAt time.Time
}

type SessionInspectResultMsg struct {
Key string
Content string
Err error
}

// TickMsg drives countdown and animation updates.
type TickMsg struct {
Now time.Time
Expand Down
21 changes: 12 additions & 9 deletions internal/tui/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,18 @@ const (

// Session represents a single opencode session entry.
type Session struct {
ID string
Project string
Title string
Directory string
LastActivity time.Time
Status SessionStatus
MessageCount int
Agents []string
Activity ActivityState
ID string
Project string
Title string
Directory string
LastActivity time.Time
Status SessionStatus
MessageCount int
Agents []string
Activity ActivityState
InspectLatestBlock string
InspectLoading bool
InspectError string
}

// JumpHop represents one hop in a ProxyJump chain.
Expand Down
Loading