feat(tui): show session workspace (cwd) in the status bar

SessionWorkspaceBoundEvent already recorded the bound workspace root but
it was never surfaced. Map it to a new session.workspace_bound frame
(sealed ServerMessage variant, auto-registered; re-emitted on reconnect
via the snapshot replay), carry it on Session.WorkspaceRoot, and show it
home-abbreviated in the status bar (⌂ ~/Programs/correx). Golden test
pins the decode.
This commit is contained in:
2026-06-03 01:56:46 +04:00
parent 00b08660bd
commit 5721ed21bb
8 changed files with 82 additions and 33 deletions
@@ -12,6 +12,7 @@ import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ChatTurnEvent import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.SessionWorkspaceBoundEvent
import com.correx.core.events.events.WorkflowStartedEvent import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.InferenceStartedEvent import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent import com.correx.core.events.events.InferenceTimeoutEvent
@@ -69,6 +70,13 @@ suspend fun domainEventToServerMessage(
sessionSequence = sessionSequence, sessionSequence = sessionSequence,
) )
is SessionWorkspaceBoundEvent -> ServerMessage.SessionWorkspaceBound(
sessionId = p.sessionId,
workspaceRoot = p.workspaceRoot,
sequence = seq,
sessionSequence = sessionSequence,
)
is ChatTurnEvent -> ServerMessage.ChatTurn( is ChatTurnEvent -> ServerMessage.ChatTurn(
sessionId = p.sessionId, sessionId = p.sessionId,
turnId = p.turnId, turnId = p.turnId,
@@ -46,6 +46,16 @@ sealed interface ServerMessage {
override val sessionSequence: Long, override val sessionSequence: Long,
) : ServerMessage, SessionMessage ) : ServerMessage, SessionMessage
/** The workspace root a session was bound to, from SessionWorkspaceBoundEvent — shown as cwd. */
@Serializable
@SerialName("session.workspace_bound")
data class SessionWorkspaceBound(
val sessionId: SessionId,
val workspaceRoot: String,
override val sequence: Long,
override val sessionSequence: Long,
) : ServerMessage, SessionMessage
/** /**
* Event-derived conversation turn (USER or ROUTER), from ChatTurnEvent. The client * Event-derived conversation turn (USER or ROUTER), from ChatTurnEvent. The client
* rebuilds the chat transcript from these, ordered by [sessionSequence]. * rebuilds the chat transcript from these, ordered by [sessionSequence].
+1
View File
@@ -73,6 +73,7 @@ func PreviewFrame(kind string, w, h int) string {
m.sessionEntered = true m.sessionEntered = true
if s := m.session("04a546aa"); s != nil { if s := m.session("04a546aa"); s != nil {
s.CurrentStage = "write_script" s.CurrentStage = "write_script"
s.WorkspaceRoot = "/home/kami/Programs/correx"
s.Events = sampleEvents() s.Events = sampleEvents()
s.Pending = &Approval{ s.Pending = &Approval{
RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM", RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM",
+14 -13
View File
@@ -98,19 +98,20 @@ type Approval struct {
// Session is the UI's view of one server session. // Session is the UI's view of one server session.
type Session struct { type Session struct {
ID string ID string
Status string Status string
WorkflowID string WorkflowID string
Name string Name string
LastEventAt int64 LastEventAt int64
CurrentStage string CurrentStage string
LastOutput string WorkspaceRoot string // bound cwd, from session.workspace_bound
LastResponse string LastOutput string
Tools []ToolRecord LastResponse string
ToolsByStage map[string][]ManifestTool Tools []ToolRecord
Events []EventEntry ToolsByStage map[string][]ManifestTool
Pending *Approval Events []EventEntry
Active bool // an inference/tool is in flight (drives the spinner) Pending *Approval
Active bool // an inference/tool is in flight (drives the spinner)
} }
// Workflow is a launchable workflow advertised by the server. // Workflow is a launchable workflow advertised by the server.
+4
View File
@@ -175,6 +175,10 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if s := m.session(msg.SessionID); s != nil { if s := m.session(msg.SessionID); s != nil {
s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID) s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
} }
case protocol.TypeWorkspaceBound:
if s := m.session(msg.SessionID); s != nil {
s.WorkspaceRoot = msg.WorkspaceRoot
}
case protocol.TypeApprovalRequired: case protocol.TypeApprovalRequired:
m.onApprovalRequired(msg) m.onApprovalRequired(msg)
case protocol.TypeApprovalResolved: case protocol.TypeApprovalResolved:
+12
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"os"
"strings" "strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -74,6 +75,9 @@ func (m Model) renderStatus() string {
if s.Status != "" { if s.Status != "" {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
} }
if s.WorkspaceRoot != "" {
parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint))
}
} }
model := m.currentModel model := m.currentModel
@@ -391,6 +395,14 @@ func (m Model) eventRows(w, h int) []string {
// --- width helpers --- // --- width helpers ---
// shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar.
func shortPath(p string) string {
if home := os.Getenv("HOME"); home != "" && strings.HasPrefix(p, home) {
return "~" + p[len(home):]
}
return p
}
// fill left-justifies a styled line and pads with bg to width w. // 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 { func (m Model) fill(s string, w int, bg lipgloss.Color) string {
return " " + padTo(s, w-1, bg) return " " + padTo(s, w-1, bg)
@@ -74,6 +74,17 @@ func TestDecodeGoldenServerFrames(t *testing.T) {
} }
}, },
}, },
{
name: "session.workspace_bound carries the cwd",
json: `{"type":"session.workspace_bound","sessionId":"s1","workspaceRoot":"/home/kami/Programs/correx","sequence":4,"sessionSequence":1}`,
wantType: TypeWorkspaceBound,
eventBear: true,
check: func(t *testing.T, m ServerMessage) {
if m.WorkspaceRoot != "/home/kami/Programs/correx" {
t.Fatalf("workspaceRoot not decoded: %+v", m)
}
},
},
{ {
name: "snapshot_complete (non-event control frame)", name: "snapshot_complete (non-event control frame)",
json: `{"type":"snapshot_complete"}`, json: `{"type":"snapshot_complete"}`,
+22 -20
View File
@@ -32,6 +32,7 @@ const (
TypeToolRejected = "tool.rejected" TypeToolRejected = "tool.rejected"
TypeApprovalRequired = "approval.required" TypeApprovalRequired = "approval.required"
TypeApprovalResolved = "approval.resolved" TypeApprovalResolved = "approval.resolved"
TypeWorkspaceBound = "session.workspace_bound"
TypeStageManifest = "stage.tool_manifest" TypeStageManifest = "stage.tool_manifest"
TypeProviderStatus = "provider.status_changed" TypeProviderStatus = "provider.status_changed"
TypeProtocolError = "protocol_error" TypeProtocolError = "protocol_error"
@@ -52,25 +53,26 @@ const (
type ServerMessage struct { type ServerMessage struct {
Type string `json:"type"` Type string `json:"type"`
SessionID string `json:"sessionId"` SessionID string `json:"sessionId"`
WorkflowID string `json:"workflowId"` WorkflowID string `json:"workflowId"`
StageID string `json:"stageId"` StageID string `json:"stageId"`
ToolName string `json:"toolName"` ToolName string `json:"toolName"`
Role string `json:"role"` Role string `json:"role"`
TurnID string `json:"turnId"` TurnID string `json:"turnId"`
Reason string `json:"reason"` Reason string `json:"reason"`
Summary string `json:"outputSummary"` Summary string `json:"outputSummary"`
Response string `json:"responseText"` Response string `json:"responseText"`
Content string `json:"content"` Content string `json:"content"`
Diff *string `json:"diff"` Diff *string `json:"diff"`
Tier string `json:"tier"` Tier string `json:"tier"`
Message string `json:"message"` Message string `json:"message"`
RequestID string `json:"requestId"` RequestID string `json:"requestId"`
ArtifactID string `json:"artifactId"` ArtifactID string `json:"artifactId"`
ProviderID string `json:"providerId"` ProviderID string `json:"providerId"`
Preview *string `json:"preview"` Preview *string `json:"preview"`
Disposition string `json:"disposition"` Disposition string `json:"disposition"`
Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED
WorkspaceRoot string `json:"workspaceRoot"` // session.workspace_bound: the bound cwd
SteeringEmitted bool `json:"steeringEmitted"` SteeringEmitted bool `json:"steeringEmitted"`
OccurredAt int64 `json:"occurredAt"` OccurredAt int64 `json:"occurredAt"`
@@ -180,7 +182,7 @@ func (m ServerMessage) IsEventBearing() bool {
TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout, TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout,
TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected, TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected,
TypeToolAssessed, TypeToolAssessed,
TypeApprovalRequired, TypeApprovalResolved, TypeArtifactCreated: TypeApprovalRequired, TypeApprovalResolved, TypeWorkspaceBound, TypeArtifactCreated:
return true return true
default: default:
return false return false