From 5721ed21bbf22c476d6cee267b3ec3aa821d2464 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 3 Jun 2026 01:56:46 +0400 Subject: [PATCH] feat(tui): show session workspace (cwd) in the status bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../apps/server/bridge/DomainEventMapper.kt | 8 ++++ .../apps/server/protocol/ServerMessage.kt | 10 +++++ apps/tui-go/internal/app/demo.go | 1 + apps/tui-go/internal/app/model.go | 27 ++++++------ apps/tui-go/internal/app/server.go | 4 ++ apps/tui-go/internal/app/view.go | 12 ++++++ apps/tui-go/internal/protocol/golden_test.go | 11 +++++ apps/tui-go/internal/protocol/protocol.go | 42 ++++++++++--------- 8 files changed, 82 insertions(+), 33 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt index 21ae5989..9aa57d23 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/DomainEventMapper.kt @@ -12,6 +12,7 @@ import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ChatTurnEvent 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.InferenceStartedEvent import com.correx.core.events.events.InferenceTimeoutEvent @@ -69,6 +70,13 @@ suspend fun domainEventToServerMessage( sessionSequence = sessionSequence, ) + is SessionWorkspaceBoundEvent -> ServerMessage.SessionWorkspaceBound( + sessionId = p.sessionId, + workspaceRoot = p.workspaceRoot, + sequence = seq, + sessionSequence = sessionSequence, + ) + is ChatTurnEvent -> ServerMessage.ChatTurn( sessionId = p.sessionId, turnId = p.turnId, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt index 43fb6c29..cd8e9cd5 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/protocol/ServerMessage.kt @@ -46,6 +46,16 @@ sealed interface ServerMessage { override val sessionSequence: Long, ) : 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 * rebuilds the chat transcript from these, ordered by [sessionSequence]. diff --git a/apps/tui-go/internal/app/demo.go b/apps/tui-go/internal/app/demo.go index da07f750..46a335b2 100644 --- a/apps/tui-go/internal/app/demo.go +++ b/apps/tui-go/internal/app/demo.go @@ -73,6 +73,7 @@ func PreviewFrame(kind string, w, h int) string { m.sessionEntered = true if s := m.session("04a546aa"); s != nil { s.CurrentStage = "write_script" + s.WorkspaceRoot = "/home/kami/Programs/correx" s.Events = sampleEvents() s.Pending = &Approval{ RequestID: "req-1", SessionID: "04a546aa", Tier: "T3", Risk: "MEDIUM", diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index 8d1aefc8..fcc3a03d 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -98,19 +98,20 @@ type Approval struct { // 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) + ID string + Status string + WorkflowID string + Name string + LastEventAt int64 + CurrentStage string + WorkspaceRoot string // bound cwd, from session.workspace_bound + 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. diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index d2651687..2e31c2f1 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -175,6 +175,10 @@ func (m *Model) applyServer(msg protocol.ServerMessage) { if s := m.session(msg.SessionID); s != nil { s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID) } + case protocol.TypeWorkspaceBound: + if s := m.session(msg.SessionID); s != nil { + s.WorkspaceRoot = msg.WorkspaceRoot + } case protocol.TypeApprovalRequired: m.onApprovalRequired(msg) case protocol.TypeApprovalResolved: diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index a16a2c95..2402009b 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -1,6 +1,7 @@ package app import ( + "os" "strings" "github.com/charmbracelet/lipgloss" @@ -74,6 +75,9 @@ func (m Model) renderStatus() string { if 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 @@ -391,6 +395,14 @@ func (m Model) eventRows(w, h int) []string { // --- 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. func (m Model) fill(s string, w int, bg lipgloss.Color) string { return " " + padTo(s, w-1, bg) diff --git a/apps/tui-go/internal/protocol/golden_test.go b/apps/tui-go/internal/protocol/golden_test.go index b427f508..ca3a66d2 100644 --- a/apps/tui-go/internal/protocol/golden_test.go +++ b/apps/tui-go/internal/protocol/golden_test.go @@ -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)", json: `{"type":"snapshot_complete"}`, diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index 00fa63b4..2bf5aa81 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -32,6 +32,7 @@ const ( TypeToolRejected = "tool.rejected" TypeApprovalRequired = "approval.required" TypeApprovalResolved = "approval.resolved" + TypeWorkspaceBound = "session.workspace_bound" TypeStageManifest = "stage.tool_manifest" TypeProviderStatus = "provider.status_changed" TypeProtocolError = "protocol_error" @@ -52,25 +53,26 @@ const ( type ServerMessage struct { Type string `json:"type"` - SessionID string `json:"sessionId"` - WorkflowID string `json:"workflowId"` - StageID string `json:"stageId"` - ToolName string `json:"toolName"` - Role string `json:"role"` - TurnID string `json:"turnId"` - 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"` - Disposition string `json:"disposition"` - Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED + SessionID string `json:"sessionId"` + WorkflowID string `json:"workflowId"` + StageID string `json:"stageId"` + ToolName string `json:"toolName"` + Role string `json:"role"` + TurnID string `json:"turnId"` + 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"` + Disposition string `json:"disposition"` + Outcome string `json:"outcome"` // approval.resolved: APPROVED | REJECTED | AUTO_APPROVED + WorkspaceRoot string `json:"workspaceRoot"` // session.workspace_bound: the bound cwd SteeringEmitted bool `json:"steeringEmitted"` OccurredAt int64 `json:"occurredAt"` @@ -180,7 +182,7 @@ func (m ServerMessage) IsEventBearing() bool { TypeInferenceStarted, TypeInferenceDone, TypeInferenceTimeout, TypeToolStarted, TypeToolCompleted, TypeToolFailed, TypeToolRejected, TypeToolAssessed, - TypeApprovalRequired, TypeApprovalResolved, TypeArtifactCreated: + TypeApprovalRequired, TypeApprovalResolved, TypeWorkspaceBound, TypeArtifactCreated: return true default: return false