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 45a05760..528bd1a9 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 @@ -5,6 +5,7 @@ import com.correx.apps.server.protocol.RiskSummaryDto import com.correx.apps.server.protocol.ServerMessage import com.correx.core.artifactstore.ArtifactStore import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.InferenceCompletedEvent import com.correx.core.events.events.WorkflowStartedEvent @@ -157,6 +158,13 @@ suspend fun domainEventToServerMessage( ) is ApprovalRequestedEvent -> mapApprovalRequested(p, seq, sessionSequence) + is ArtifactCreatedEvent -> ServerMessage.ArtifactCreated( + sessionId = p.sessionId, + stageId = p.stageId, + artifactId = p.artifactId, + sequence = seq, + sessionSequence = sessionSequence, + ) else -> { log.debug( "DomainEventMapper: unmapped payload type={} sessionId={} sequence={}", diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt index fd9fdb79..1dd4331e 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/bridge/SessionEventBridge.kt @@ -19,6 +19,7 @@ import com.correx.core.events.events.InferenceStartedEvent import com.correx.core.events.events.InferenceTimeoutEvent import com.correx.core.events.events.NewEvent import com.correx.core.events.events.ApprovalRequestedEvent +import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.OrchestrationPausedEvent import com.correx.core.events.events.OrchestrationResumedEvent import com.correx.core.events.events.StageCompletedEvent @@ -183,6 +184,7 @@ class SessionEventBridge( is OrchestrationPausedEvent -> EventEntryDto(ts, "SessionPaused", p.reason) is WorkflowCompletedEvent -> EventEntryDto(ts, "SessionCompleted", "") is WorkflowFailedEvent -> EventEntryDto(ts, "SessionFailed", p.reason) + is ArtifactCreatedEvent -> EventEntryDto(ts, "ArtifactCreated", p.artifactId.value) else -> null } } 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 53a60b6f..6372a75b 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 @@ -2,6 +2,7 @@ package com.correx.apps.server.protocol import com.correx.core.approvals.Tier import com.correx.core.events.types.ApprovalRequestId +import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.SessionId import com.correx.core.events.types.StageId import kotlinx.serialization.SerialName @@ -251,6 +252,18 @@ sealed interface ServerMessage { override val sessionSequence: Long? = null, ) : ServerMessage, NonEventMessage + // -- Artifacts -- + + @Serializable + @SerialName("artifact.created") + data class ArtifactCreated( + val sessionId: SessionId, + val stageId: StageId, + val artifactId: ArtifactId, + override val sequence: Long, + override val sessionSequence: Long, + ) : ServerMessage, SessionMessage + // -- Router -- @Serializable diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt index e00ae9a6..8e9fc9d2 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/KeyEvent.kt @@ -21,4 +21,5 @@ sealed class KeyEvent { object CursorRight : KeyEvent() object ToggleWorkflows : KeyEvent() object ToggleSteeringMode : KeyEvent() + object ToggleDiffExpand : KeyEvent() } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt index 9deb46d1..aa43089b 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/ApprovalSurface.kt @@ -26,6 +26,8 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph { val approval = state.selectedPendingApproval() val pendingDecision = state.pendingDecision + val diffExpanded = state.diffExpanded + val lines = buildList { if (approval == null) { add(Line.from(Span.styled("no pending approval", dimStyle))) @@ -43,10 +45,14 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph { if (preview != null) { val previewLines = preview.lines() if (previewLines.isNotEmpty()) { - val visibleLines = previewLines.take(PREVIEW_MAX_LINES) + val visibleLines = if (diffExpanded) { + previewLines + } else { + previewLines.take(PREVIEW_MAX_LINES) + } for (line in visibleLines) { if (isDiff) { - val truncated = if (line.length > DIFF_LINE_MAX_LENGTH) { + val truncated = if (line.length > DIFF_LINE_MAX_LENGTH && !diffExpanded) { line.take(DIFF_LINE_MAX_LENGTH - 3) + "..." } else { line @@ -63,9 +69,12 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph { add(Line.from(Span.raw(" $line"))) } } - if (previewLines.size > PREVIEW_MAX_LINES) { + if (!diffExpanded && previewLines.size > PREVIEW_MAX_LINES) { val remaining = previewLines.size - PREVIEW_MAX_LINES - add(Line.from(Span.styled(" (preview truncated, $remaining more lines)", dimStyle))) + add(Line.from(Span.styled(" (preview truncated, $remaining more lines — ctrl+x to expand)", dimStyle))) + } + if (diffExpanded) { + add(Line.from(Span.styled(" (full diff — ctrl+x to collapse)", dimStyle))) } } else { add(Line.from(Span.styled(" no preview available", dimStyle))) @@ -87,7 +96,7 @@ fun approvalSurfaceWidget(state: TuiState): Paragraph { // Approve/reject key hints add(Line.from(Span.styled( - " ctrl+a approve \u00B7 ctrl+r reject \u00B7 enter submit \u00B7 esc dismiss", + " ctrl+a approve \u00B7 ctrl+r reject \u00B7 ctrl+x toggle diff \u00B7 enter submit \u00B7 esc dismiss", dimStyle ))) } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt index 64aef8a4..0eae49b6 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/EventHistoryStrip.kt @@ -20,14 +20,13 @@ fun eventHistoryStripWidget(state: TuiState): Paragraph { } else { // Stage ID and status line (compact, dim metadata) val stageInfo = buildString { - if (selectedSession.currentStageId != null) { - append(selectedSession.currentStageId) + val stageId = selectedSession.currentStageId + val stage = selectedSession.currentStage + when { + stageId != null -> append(stageId) + stage != null -> append(stage) + else -> append("(no stage)") } - if (selectedSession.currentStage != null) { - if (isNotEmpty()) append(" · ") - append(selectedSession.currentStage) - } - if (isEmpty()) append("(no stage)") append(" ") append(selectedSession.status) } @@ -46,6 +45,7 @@ fun eventHistoryStripWidget(state: TuiState): Paragraph { "StageFailed", "SessionFailed", "ToolFailed", "ToolRejected" -> Style.create().red() "SessionPaused", "InferenceTimeout" -> Style.create().yellow() "StageStarted", "InferenceStarted", "ToolStarted" -> Style.create().blue() + "ArtifactCreated" -> Style.create().magenta() else -> Style.create() } val detail = if (ev.detail.length > DETAIL_MAX) { diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt index ab57e9e6..caa60103 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/components/SessionList.kt @@ -31,7 +31,11 @@ fun sessionListWidget(state: TuiState): Paragraph { val statusSpan = statusSpan(s) val rawName = s.name.ifEmpty { s.workflowId } val truncated = if (rawName.length > NAME_MAX) rawName.take(NAME_MAX - 1) + "…" else rawName - add(Line.from(prefix, idSpan, Span.raw(" "), statusSpan, Span.raw(" $truncated"))) + val nameAndStage = buildString { + append(truncated) + if (s.currentStageId != null) append(" [${s.currentStageId}]") + } + add(Line.from(prefix, idSpan, Span.raw(" "), statusSpan, Span.raw(" $nameAndStage"))) } if (overflow > 0) { add(Line.from(Span.styled("— $overflow more —", dimStyle))) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt index 05a978cd..6bfae68f 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/Action.kt @@ -28,5 +28,6 @@ sealed interface Action { data object CursorRight : Action data object ToggleWorkflows : Action data object CycleChatMode : Action + data object ToggleDiffExpand : Action data object NoOp : Action } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt index f6e5eb41..c1aa61a5 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/KeyResolver.kt @@ -74,6 +74,7 @@ object KeyResolver { KeyEvent.Cancel -> Action.CancelInput KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip + KeyEvent.ToggleDiffExpand -> Action.ToggleDiffExpand KeyEvent.Tab -> Action.CycleMode KeyEvent.Backspace -> Action.Backspace is KeyEvent.CharInput -> Action.AppendChar(key.ch) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt index 04fc30d3..71ef85d2 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/input/TambouiKeyMapper.kt @@ -25,6 +25,7 @@ fun mapKey(event: TambouiKeyEvent): KeyEvent? { event.isChar('h') -> KeyEvent.ShowPendingApproval event.isChar('w') -> KeyEvent.ToggleWorkflows event.isChar('s') -> KeyEvent.ToggleSteeringMode + event.isChar('x') -> KeyEvent.ToggleDiffExpand else -> null } } diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt index a2914342..c9855420 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/RootReducer.kt @@ -118,6 +118,8 @@ object RootReducer { else withBgReset.copy(eventStripVisible = !withBgReset.eventStripVisible) } + is Action.ToggleDiffExpand -> withBgReset.copy(diffExpanded = !withBgReset.diffExpanded) + // Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1), // or enter a selected session from the list in IDLE. is Action.SubmitInput -> { @@ -167,6 +169,7 @@ object RootReducer { } msg is ServerMessage.RouterResponseMessage -> { withBgReset.copy( + routerConnected = true, routerMessages = withBgReset.routerMessages.run { val key = msg.sessionId.value this + (key to (this[key].orEmpty() + msg.content)) diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt index d2be71bf..99a96dce 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/reducer/SessionsReducer.kt @@ -207,6 +207,7 @@ object SessionsReducer { is ServerMessage.SessionSnapshot -> processSessionSnapshotMessage(clock, sessions, msg) is ServerMessage.ApprovalRequired -> processApprovalRequiredMessage(sessions, msg) is ServerMessage.StageToolManifest -> processStageToolManifestMessage(sessions, msg) + is ServerMessage.ArtifactCreated -> processArtifactCreatedMessage(sessions, msg) is ServerMessage.WorkflowList -> processWorkflowListMessage(sessions, msg) else -> sessions to emptyList() } @@ -231,6 +232,7 @@ object SessionsReducer { is ServerMessage.ToolRejected -> msg.sessionId.value is ServerMessage.ApprovalRequired -> msg.sessionId.value is ServerMessage.SessionSnapshot -> msg.sessionId.value + is ServerMessage.ArtifactCreated -> msg.sessionId.value // Control/non-event messages do not count per ARCH-BADGE-1: is ServerMessage.StageToolManifest -> null is ServerMessage.SnapshotComplete -> null @@ -627,6 +629,30 @@ object SessionsReducer { }, ) + private fun processArtifactCreatedMessage( + sessions: SessionsState, + msg: ServerMessage.ArtifactCreated, + ): Pair> { + val now = msg.sequence + val entry = TuiEventEntry( + timestamp = formatTime(now), + type = "ArtifactCreated", + detail = msg.artifactId.value, + ) + return sessions.copy( + sessions = sessions.sessions.map { s -> + if (s.id == msg.sessionId.value) { + s.copy( + recentEvents = (s.recentEvents + entry).takeLast(7), + lastEventAt = now, + ) + } else { + s + } + }, + ) to emptyList() + } + private fun processWorkflowListMessage( sessions: SessionsState, msg: ServerMessage.WorkflowList, diff --git a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt index b6070356..dea9bcde 100644 --- a/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt +++ b/apps/tui/src/main/kotlin/com/correx/apps/tui/state/TuiState.kt @@ -47,6 +47,7 @@ data class TuiState( val providerType: ProviderType = ProviderType.LOCAL, val routerConnected: Boolean = false, val routerMessages: Map> = emptyMap(), + val diffExpanded: Boolean = false, val chatMode: ChatMode = ChatMode.CHAT, /** * True from connect until SnapshotComplete observed. Reset to true on Disconnected.