feat(guardrails): steering channel + shell-in-file rule + capability-gap detector
Bundles three operator-reliability guardrails (Vikunja #28/#29/#30) plus the in-flight branch WIP they were built on top of (reasoning_content capture, operator/project profile editor, write-jail workspaceRoot fix) — the tree is interdependent (SessionOrchestrator references reasoningArtifactId from the WIP) and does not compile as separable subsets, so it lands as one commit. Guardrails: - #28 mid-stage steering: ClientMessage.SteerSession -> GlobalStreamHandler -> orchestrator.submitSteering, reusing SteeringNoteAddedEvent + existing context fold (advisory, non-authoritative; invariants #3/#7). Closes the gap where steering typed off an approval gate was silently dropped. - #29 shell-in-file guardrail: ShellInFileContentRule (core:toolintent) blocks a file_write whose content is a bare shell command (e.g. "mkdir -p ..."); FileWriteTool description now advertises auto-mkdir of parent dirs. Basename-allowlist so the extensionless case is caught; scripts/Makefiles/multiline exempt. - #30 pt1 capability-gap detector: deterministic CapabilityGapDetector maps stage intent -> implied ToolCapability, compares to granted tools, emits advisory CapabilityGapDetectedEvent in FreestyleDriver.lockAndRun. Recorded, never fails the gate and never auto-grants (invariants #3/#4/#5). Reflection rung is pt2. Verified: ./gradlew check green (whole tree).
This commit is contained in:
@@ -68,6 +68,7 @@ import com.correx.core.toolintent.rules.NetworkHostRule
|
||||
import com.correx.core.toolintent.rules.PathContainmentRule
|
||||
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
|
||||
import com.correx.core.toolintent.rules.ReferenceExistsRule
|
||||
import com.correx.core.toolintent.rules.ShellInFileContentRule
|
||||
import com.correx.core.toolintent.rules.StaleWriteRule
|
||||
import com.correx.core.toolintent.rules.WriteScopeRule
|
||||
import com.correx.apps.server.freestyle.FreestyleDriver
|
||||
@@ -200,15 +201,20 @@ fun main() {
|
||||
?.let { Path.of(it) }
|
||||
?: toolsConfig.sandboxRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
|
||||
?: Paths.get(System.getProperty("user.home"), ".config", "correx", "sandbox")
|
||||
val workingDir = System.getenv("CORREX_WORKING_DIR")
|
||||
val explicitWorkingDir = System.getenv("CORREX_WORKING_DIR")
|
||||
?.let { Path.of(it) }
|
||||
?: toolsConfig.workingDir.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
|
||||
?: Path.of("").toAbsolutePath()
|
||||
val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet()
|
||||
val workspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
|
||||
val explicitWorkspaceRoot = System.getenv("CORREX_WORKSPACE_ROOT")
|
||||
?.let { Path.of(it) }
|
||||
?: toolsConfig.workspaceRoot.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
|
||||
?: workingDir
|
||||
// workingDir and workspaceRoot must resolve to the same tree by default — the tool-call
|
||||
// assessor's containment rules (PathContainmentRule, ManifestContainmentRule) only ever see
|
||||
// workspaceRoot, while FileWriteTool resolves relative paths against workingDir. If only one
|
||||
// is configured, each falls back to the other before falling back to process CWD, so the
|
||||
// assessor's containment check and the actual write always share one resolved root.
|
||||
val shellAllowedExecutables = toolsConfig.shellAllowedExecutables.toSet()
|
||||
val workspaceRoot = explicitWorkspaceRoot ?: explicitWorkingDir ?: Path.of("").toAbsolutePath()
|
||||
val workingDir = explicitWorkingDir ?: workspaceRoot
|
||||
// One shared HTTP client backs both the default and per-workspace registries' research tools
|
||||
// (web_search/web_fetch). Built only when research is enabled, so the static path stays offline.
|
||||
val researchToolConfig = com.correx.infrastructure.tools.ResearchToolConfig(
|
||||
@@ -274,6 +280,7 @@ fun main() {
|
||||
PathContainmentRule(),
|
||||
ReadBeforeWriteRule(),
|
||||
ReferenceExistsRule(),
|
||||
ShellInFileContentRule(),
|
||||
WriteScopeRule(),
|
||||
StaleWriteRule(),
|
||||
ManifestContainmentRule(),
|
||||
@@ -510,6 +517,7 @@ fun main() {
|
||||
config = defaultOrchestrationConfig,
|
||||
runPhase2 = { sid, graph, cfg -> orchestrator.run(sid, graph, cfg) },
|
||||
requestPlanApproval = { sid, planJson -> orchestrator.requestPlanApproval(sid, planJson) },
|
||||
toolCapabilities = toolRegistry.all().associate { it.name to it.requiredCapabilities },
|
||||
)
|
||||
// observability-spec §4: continuous health watch. Seed the monitor's last-status from the
|
||||
// recorded system-session events so a restart doesn't re-emit a degraded already in the log.
|
||||
|
||||
@@ -340,6 +340,7 @@ private suspend fun mapInferenceCompleted(
|
||||
outputSummary = this,
|
||||
responseText = this,
|
||||
occurredAt = event.metadata.timestamp.toEpochMilliseconds(),
|
||||
totalTokens = p.tokensUsed.totalTokens,
|
||||
sequence = event.sequence,
|
||||
sessionSequence = sessionSequence,
|
||||
)
|
||||
|
||||
@@ -181,7 +181,7 @@ class SessionEventBridge(
|
||||
ToolDecl(name = tool.name, tier = tool.tier.level)
|
||||
}
|
||||
}
|
||||
StageToolDecl(stageId = stageId.value, tools = tools)
|
||||
StageToolDecl(stageId = stageId.value, tools = tools, tokenBudget = stageConfig.tokenBudget)
|
||||
}
|
||||
send(ServerMessage.StageToolManifest(
|
||||
sessionId = sessionId,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package com.correx.apps.server.freestyle
|
||||
|
||||
import com.correx.core.events.events.CapabilityGapDetectedEvent
|
||||
import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.tools.contract.ToolCapability
|
||||
import com.correx.infrastructure.workflow.CapabilityGapDetector
|
||||
import com.correx.infrastructure.workflow.PlanLintResult
|
||||
import com.correx.infrastructure.workflow.PlanLinter
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.EventId
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
@@ -26,6 +30,7 @@ import java.util.UUID
|
||||
* [runPhase2] is a functional seam so the driver can be tested without the full
|
||||
* [DefaultSessionOrchestrator] constructor chain.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
class FreestyleDriver(
|
||||
private val eventStore: EventStore,
|
||||
private val compiler: ExecutionPlanCompiler,
|
||||
@@ -33,6 +38,10 @@ class FreestyleDriver(
|
||||
private val config: OrchestrationConfig,
|
||||
private val runPhase2: suspend (SessionId, WorkflowGraph, OrchestrationConfig) -> WorkflowResult,
|
||||
private val requestPlanApproval: suspend (SessionId, String) -> Boolean = { _, _ -> true },
|
||||
// Tool name -> the capabilities that tool grants (Tool.requiredCapabilities), used only by the
|
||||
// capability-gap detector below. Empty = detector finds nothing (preserves callers/tests that
|
||||
// don't care about it) rather than depending on a live ToolRegistry here.
|
||||
private val toolCapabilities: Map<String, Set<ToolCapability>> = emptyMap(),
|
||||
) {
|
||||
suspend fun lockAndRun(sessionId: SessionId) {
|
||||
val json = planContent(sessionId) ?: run {
|
||||
@@ -59,6 +68,16 @@ class FreestyleDriver(
|
||||
emitRejected(sessionId, "plan failed lint: $summary", "lint")
|
||||
return
|
||||
}
|
||||
// Capability-gap detector (Vikunja #30 part 1): advisory only — recorded, never blocking.
|
||||
// A gap does not fail the gate and does not grant the missing tool (invariants #3/#4/#5); a
|
||||
// future reflection rung (part 2) is what will act on these events.
|
||||
CapabilityGapDetector.detect(graph, toolCapabilities).forEach { gap ->
|
||||
log.info(
|
||||
"[FreestyleDriver] capability gap session={} stage={} implied={} evidence='{}'",
|
||||
sessionId.value, gap.stageId, gap.impliedCapability, gap.phrase,
|
||||
)
|
||||
emitCapabilityGap(sessionId, gap.stageId, gap.impliedCapability.name, gap.phrase)
|
||||
}
|
||||
val approved = requestPlanApproval(sessionId, json)
|
||||
if (!approved) {
|
||||
log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value)
|
||||
@@ -121,6 +140,33 @@ class FreestyleDriver(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitCapabilityGap(
|
||||
sessionId: SessionId,
|
||||
stageId: String,
|
||||
impliedCapability: String,
|
||||
evidence: String,
|
||||
) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = CapabilityGapDetectedEvent(
|
||||
sessionId = sessionId,
|
||||
stageId = StageId(stageId),
|
||||
impliedCapability = impliedCapability,
|
||||
evidence = evidence,
|
||||
timestampMs = Clock.System.now().toEpochMilliseconds(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
|
||||
@@ -86,6 +86,32 @@ sealed class ClientMessage {
|
||||
@Serializable
|
||||
data class UpdateConfig(val patch: Map<String, String>) : ClientMessage()
|
||||
|
||||
/** Operator request for the ProjectProfile bound to [sessionId]'s workspace. */
|
||||
@Serializable
|
||||
data class GetProjectProfile(val sessionId: SessionId) : ClientMessage()
|
||||
|
||||
/** Operator request to overwrite the ProjectProfile at [sessionId]'s workspace root. */
|
||||
@Serializable
|
||||
data class UpdateProjectProfile(
|
||||
val sessionId: SessionId,
|
||||
val about: String,
|
||||
val conventions: List<String>,
|
||||
val commands: Map<String, String>,
|
||||
) : ClientMessage()
|
||||
|
||||
/** Operator request for the current OperatorProfile (home-scoped, not session-bound). */
|
||||
@Serializable
|
||||
data object GetOperatorProfile : ClientMessage()
|
||||
|
||||
/** Operator request to overwrite the OperatorProfile. */
|
||||
@Serializable
|
||||
data class UpdateOperatorProfile(
|
||||
val about: String,
|
||||
val approvalMode: String,
|
||||
val preferredModels: List<String>,
|
||||
val conventions: List<String>,
|
||||
) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class ApprovalResponse(
|
||||
val requestId: ApprovalRequestId,
|
||||
@@ -102,6 +128,18 @@ sealed class ClientMessage {
|
||||
val answers: List<ClarificationAnswer>,
|
||||
) : ClientMessage()
|
||||
|
||||
/**
|
||||
* Free-form operator steering for a session that is mid-stage, independent of any approval
|
||||
* gate. Unlike [ApprovalResponse.steeringNote] (only deliverable while parked on an approval)
|
||||
* this can be sent at any time; the orchestrator records it and folds it into the stage's next
|
||||
* context turn as advisory input (never a state mutation — see SessionOrchestrator.submitSteering).
|
||||
*/
|
||||
@Serializable
|
||||
data class SteerSession(
|
||||
val sessionId: SessionId,
|
||||
val text: String,
|
||||
) : ClientMessage()
|
||||
|
||||
@Serializable
|
||||
data class CreateGrant(
|
||||
val sessionId: SessionId,
|
||||
|
||||
@@ -57,6 +57,7 @@ enum class PauseReason {
|
||||
data class StageToolDecl(
|
||||
val stageId: String,
|
||||
val tools: List<ToolDecl>,
|
||||
val tokenBudget: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -189,6 +189,7 @@ sealed interface ServerMessage {
|
||||
val outputSummary: String,
|
||||
val responseText: String = "",
|
||||
val occurredAt: Long,
|
||||
val totalTokens: Int? = null,
|
||||
override val sequence: Long,
|
||||
override val sessionSequence: Long,
|
||||
) : ServerMessage, SessionMessage
|
||||
@@ -546,6 +547,33 @@ sealed interface ServerMessage {
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
/** The ProjectProfile bound to a session's workspace (reply to GetProjectProfile / UpdateProjectProfile). */
|
||||
@Serializable
|
||||
@SerialName("project_profile.snapshot")
|
||||
data class ProjectProfileSnapshot(
|
||||
val sessionId: SessionId,
|
||||
val about: String,
|
||||
val conventions: List<String>,
|
||||
val commands: Map<String, String>,
|
||||
val error: String? = null,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
/** The operator's OperatorProfile (reply to GetOperatorProfile / UpdateOperatorProfile). */
|
||||
@Serializable
|
||||
@SerialName("operator_profile.snapshot")
|
||||
data class OperatorProfileSnapshot(
|
||||
val about: String,
|
||||
val approvalMode: String,
|
||||
val preferredModels: List<String>,
|
||||
val conventions: List<String>,
|
||||
val proposedAdaptation: String? = null,
|
||||
val error: String? = null,
|
||||
override val sequence: Long? = null,
|
||||
override val sessionSequence: Long? = null,
|
||||
) : ServerMessage, NonEventMessage
|
||||
|
||||
// -- Router --
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -32,6 +32,11 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.inference.ProviderHealth
|
||||
import com.correx.core.kernel.orchestration.WorkspaceContext
|
||||
import com.correx.core.config.OperatorProfile
|
||||
import com.correx.core.config.OperatorProfileWriter
|
||||
import com.correx.core.config.ProfileLoader
|
||||
import com.correx.core.config.ProfilePreferences
|
||||
import com.correx.core.config.ProjectProfile
|
||||
import com.correx.core.config.ProjectProfileLoader
|
||||
import com.correx.core.config.ProjectProfileWriter
|
||||
import com.correx.core.config.withConvention
|
||||
@@ -56,6 +61,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Clock
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
|
||||
private val log = LoggerFactory.getLogger(GlobalStreamHandler::class.java)
|
||||
@@ -244,9 +250,14 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
is ClientMessage.GetHealthChecks -> sendFrame(queries.healthChecks())
|
||||
is ClientMessage.GetConfig -> sendFrame(queries.configSnapshot(error = null, restartRequired = emptyList()))
|
||||
is ClientMessage.UpdateConfig -> sendFrame(queries.updateConfig(msg.patch))
|
||||
is ClientMessage.GetProjectProfile -> handleGetProjectProfile(msg, sendFrame)
|
||||
is ClientMessage.UpdateProjectProfile -> handleUpdateProjectProfile(msg, sendFrame)
|
||||
is ClientMessage.GetOperatorProfile -> handleGetOperatorProfile(sendFrame)
|
||||
is ClientMessage.UpdateOperatorProfile -> handleUpdateOperatorProfile(msg, sendFrame)
|
||||
is ClientMessage.ResumeSession -> session.send(encodeError("ResumeSession not supported"))
|
||||
is ClientMessage.ApprovalResponse -> handleApprovalResponse(msg, sendFrame)
|
||||
is ClientMessage.ClarificationResponse -> handleClarificationResponse(msg)
|
||||
is ClientMessage.SteerSession -> handleSteerSession(msg)
|
||||
is ClientMessage.CreateGrant -> handleCreateGrant(msg, sendFrame)
|
||||
is ClientMessage.RevokeGrant -> handleRevokeGrant(msg, sendFrame)
|
||||
is ClientMessage.ListGrants -> sendFrame(queries.listGrants())
|
||||
@@ -318,6 +329,16 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
}
|
||||
}
|
||||
|
||||
// Free-form mid-stage steering, independent of any approval gate — see
|
||||
// DefaultSessionOrchestrator.submitSteering and ClientMessage.SteerSession.
|
||||
private suspend fun handleSteerSession(msg: ClientMessage.SteerSession) {
|
||||
runCatching {
|
||||
module.orchestrator.submitSteering(msg.sessionId, msg.text)
|
||||
}.onFailure {
|
||||
log.error("submitSteering failed for session={}: {}", msg.sessionId.value, it.message, it)
|
||||
}
|
||||
}
|
||||
|
||||
// Tombstone an idea, landing the discard in the session that captured it (so all of one idea's
|
||||
// events stay co-located), then reply with the refreshed board. A missing idea is a no-op.
|
||||
private suspend fun handleDiscardIdea(
|
||||
@@ -397,6 +418,131 @@ class GlobalStreamHandler(private val module: ServerModule) {
|
||||
.lastOrNull()
|
||||
?.workspaceRoot
|
||||
|
||||
private suspend fun handleGetProjectProfile(
|
||||
msg: ClientMessage.GetProjectProfile,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
val workspaceRoot = workspaceRootOf(msg.sessionId)
|
||||
if (workspaceRoot == null) {
|
||||
sendFrame(
|
||||
ServerMessage.ProjectProfileSnapshot(
|
||||
sessionId = msg.sessionId,
|
||||
about = "",
|
||||
conventions = emptyList(),
|
||||
commands = emptyMap(),
|
||||
error = "session has no bound workspace",
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
val profile = withContext(Dispatchers.IO) { ProjectProfileLoader.load(workspaceRoot) }
|
||||
sendFrame(
|
||||
ServerMessage.ProjectProfileSnapshot(
|
||||
sessionId = msg.sessionId,
|
||||
about = profile.about,
|
||||
conventions = profile.conventions,
|
||||
commands = profile.commands,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleUpdateProjectProfile(
|
||||
msg: ClientMessage.UpdateProjectProfile,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
val workspaceRoot = workspaceRootOf(msg.sessionId)
|
||||
if (workspaceRoot == null) {
|
||||
sendFrame(
|
||||
ServerMessage.ProjectProfileSnapshot(
|
||||
sessionId = msg.sessionId,
|
||||
about = "",
|
||||
conventions = emptyList(),
|
||||
commands = emptyMap(),
|
||||
error = "session has no bound workspace",
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
val profile = ProjectProfile(about = msg.about, conventions = msg.conventions, commands = msg.commands)
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { ProjectProfileWriter.write(workspaceRoot, profile) }
|
||||
}.onFailure {
|
||||
log.error("updateProjectProfile failed for session={}: {}", msg.sessionId.value, it.message, it)
|
||||
sendFrame(
|
||||
ServerMessage.ProjectProfileSnapshot(
|
||||
sessionId = msg.sessionId,
|
||||
about = profile.about,
|
||||
conventions = profile.conventions,
|
||||
commands = profile.commands,
|
||||
error = "failed to save: ${it.message}",
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
sendFrame(
|
||||
ServerMessage.ProjectProfileSnapshot(
|
||||
sessionId = msg.sessionId,
|
||||
about = profile.about,
|
||||
conventions = profile.conventions,
|
||||
commands = profile.commands,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleGetOperatorProfile(sendFrame: suspend (ServerMessage) -> Unit) {
|
||||
val profile = withContext(Dispatchers.IO) { ProfileLoader.load() }
|
||||
val proposedPath = ProfileLoader.profilePath().resolveSibling("profile.proposed.toml")
|
||||
val proposed = withContext(Dispatchers.IO) {
|
||||
runCatching { if (Files.exists(proposedPath)) Files.readString(proposedPath) else null }.getOrNull()
|
||||
}
|
||||
sendFrame(
|
||||
ServerMessage.OperatorProfileSnapshot(
|
||||
about = profile.about,
|
||||
approvalMode = profile.preferences.approvalMode,
|
||||
preferredModels = profile.preferences.preferredModels,
|
||||
conventions = profile.preferences.conventions,
|
||||
proposedAdaptation = proposed,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleUpdateOperatorProfile(
|
||||
msg: ClientMessage.UpdateOperatorProfile,
|
||||
sendFrame: suspend (ServerMessage) -> Unit,
|
||||
) {
|
||||
val profile = OperatorProfile(
|
||||
about = msg.about,
|
||||
preferences = ProfilePreferences(
|
||||
approvalMode = msg.approvalMode,
|
||||
preferredModels = msg.preferredModels,
|
||||
conventions = msg.conventions,
|
||||
),
|
||||
)
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) { OperatorProfileWriter.write(profile) }
|
||||
}.onFailure {
|
||||
log.error("updateOperatorProfile failed: {}", it.message, it)
|
||||
sendFrame(
|
||||
ServerMessage.OperatorProfileSnapshot(
|
||||
about = profile.about,
|
||||
approvalMode = profile.preferences.approvalMode,
|
||||
preferredModels = profile.preferences.preferredModels,
|
||||
conventions = profile.preferences.conventions,
|
||||
error = "failed to save: ${it.message}",
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
sendFrame(
|
||||
ServerMessage.OperatorProfileSnapshot(
|
||||
about = profile.about,
|
||||
approvalMode = profile.preferences.approvalMode,
|
||||
preferredModels = profile.preferences.preferredModels,
|
||||
conventions = profile.preferences.conventions,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun errorResponse(message: String) = ServerMessage.ProtocolError(message)
|
||||
|
||||
private fun encodeError(message: String): Frame.Text =
|
||||
|
||||
@@ -260,7 +260,8 @@ class DomainEventMapperTest {
|
||||
val result = domainEventToServerMessage(event, store, sessionSequence = 0L)
|
||||
assertEquals(
|
||||
ServerMessage.InferenceCompleted(
|
||||
sessionId, stageId, responseText, responseText, occurredAt, event.sequence, 0L,
|
||||
sessionId, stageId, responseText, responseText, occurredAt,
|
||||
totalTokens = 15, sequence = event.sequence, sessionSequence = 0L,
|
||||
),
|
||||
result,
|
||||
)
|
||||
@@ -281,7 +282,10 @@ class DomainEventMapperTest {
|
||||
)
|
||||
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
|
||||
assertEquals(
|
||||
ServerMessage.InferenceCompleted(sessionId, stageId, "", "", occurredAt, event.sequence, 0L),
|
||||
ServerMessage.InferenceCompleted(
|
||||
sessionId, stageId, "", "", occurredAt,
|
||||
totalTokens = 15, sequence = event.sequence, sessionSequence = 0L,
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ const (
|
||||
OverlayGrants
|
||||
OverlayGrantScope
|
||||
OverlayHelp
|
||||
OverlayProjectProfile
|
||||
OverlayOperatorProfile
|
||||
)
|
||||
|
||||
// RouterEntry is one line in a session's conversation transcript.
|
||||
@@ -109,6 +111,13 @@ type ManifestTool struct {
|
||||
Tier int
|
||||
}
|
||||
|
||||
// ProfileField is one editable row in the project/operator profile editors — a flat
|
||||
// key/value text field, mirroring the config editor's STRING-field editing model.
|
||||
type ProfileField struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// Approval is a pending approval gate for a session.
|
||||
type Approval struct {
|
||||
RequestID string
|
||||
@@ -179,18 +188,20 @@ type Proposal 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
|
||||
WorkspaceRoot string // bound cwd, from session.workspace_bound
|
||||
LastOutput string
|
||||
LastResponse string
|
||||
Tools []ToolRecord
|
||||
ToolsByStage map[string][]ManifestTool
|
||||
Events []EventEntry
|
||||
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
|
||||
TokenBudgetByStage map[string]int // stage -> ceiling, from stage.tool_manifest
|
||||
StageTokensUsed int // tokens used by the most recent inference in CurrentStage
|
||||
Events []EventEntry
|
||||
// Pending is the *current* approval gate shown in the band. It always mirrors
|
||||
// PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can
|
||||
// keep reading a single pointer while multiple gates queue up behind it.
|
||||
@@ -301,6 +312,29 @@ type Model struct {
|
||||
configRestart []string // keys from the last save that need a restart
|
||||
configLoading bool
|
||||
|
||||
// project profile editor (OverlayProjectProfile) — populated by project_profile.snapshot;
|
||||
// mirrors the config editor's field-list/staged-edit pattern. fields[0] is "about",
|
||||
// fields[1] is "conventions" (semicolon-joined), the rest are one row per command.
|
||||
projectProfileFields []ProfileField
|
||||
projectProfileIndex int
|
||||
projectProfileStaged map[string]string
|
||||
projectProfileEditing bool
|
||||
projectProfileEditBuf string
|
||||
projectProfileError string
|
||||
projectProfileLoading bool
|
||||
|
||||
// operator profile editor (OverlayOperatorProfile) — populated by operator_profile.snapshot.
|
||||
// fields are "about", "approvalMode", "preferredModels" (comma-joined), "conventions"
|
||||
// (semicolon-joined). proposedAdaptation is read-only (from ProfileAdaptationService).
|
||||
operatorProfileFields []ProfileField
|
||||
operatorProfileIndex int
|
||||
operatorProfileStaged map[string]string
|
||||
operatorProfileEditing bool
|
||||
operatorProfileEditBuf string
|
||||
operatorProfileError string
|
||||
operatorProfileLoading bool
|
||||
operatorProfileProposed string
|
||||
|
||||
// session stats (OverlayStats) — populated by the session.stats reply
|
||||
stats *protocol.StatsDto
|
||||
statsFor string // sessionId the current stats belong to
|
||||
@@ -413,20 +447,22 @@ type Model struct {
|
||||
// NewModel builds the initial idle state.
|
||||
func NewModel(client *ws.Client) Model {
|
||||
return Model{
|
||||
client: client,
|
||||
theme: NewTheme(SoftBlue),
|
||||
wfIndex: -1,
|
||||
inputMode: ModeRouter,
|
||||
historyIndex: -1,
|
||||
routerMessages: map[string][]RouterEntry{},
|
||||
configStaged: map[string]string{},
|
||||
chatMode: ChatModeChat,
|
||||
providerType: "LOCAL",
|
||||
snapshotPhase: true,
|
||||
eventStripShown: true,
|
||||
transcriptSel: -1,
|
||||
sbHidden: loadStatusbarHidden(),
|
||||
actionsHidden: loadPrefs().InlineActionsHidden,
|
||||
client: client,
|
||||
theme: NewTheme(SoftBlue),
|
||||
wfIndex: -1,
|
||||
inputMode: ModeRouter,
|
||||
historyIndex: -1,
|
||||
routerMessages: map[string][]RouterEntry{},
|
||||
configStaged: map[string]string{},
|
||||
projectProfileStaged: map[string]string{},
|
||||
operatorProfileStaged: map[string]string{},
|
||||
chatMode: ChatModeChat,
|
||||
providerType: "LOCAL",
|
||||
snapshotPhase: true,
|
||||
eventStripShown: true,
|
||||
transcriptSel: -1,
|
||||
sbHidden: loadStatusbarHidden(),
|
||||
actionsHidden: loadPrefs().InlineActionsHidden,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/correx/tui-go/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
operatorProfileFieldAbout = "about"
|
||||
operatorProfileFieldApprovalMode = "approvalMode"
|
||||
operatorProfileFieldPreferredModels = "preferredModels"
|
||||
operatorProfileFieldConventions = "conventions"
|
||||
)
|
||||
|
||||
// openOperatorProfile opens the operator profile editor (home-scoped, not session-bound).
|
||||
func (m *Model) openOperatorProfile() {
|
||||
m.overlay = OverlayOperatorProfile
|
||||
m.operatorProfileIndex = 0
|
||||
m.operatorProfileEditing = false
|
||||
m.operatorProfileEditBuf = ""
|
||||
m.operatorProfileError = ""
|
||||
m.operatorProfileStaged = map[string]string{}
|
||||
m.operatorProfileLoading = true
|
||||
m.client.Send(protocol.GetOperatorProfile())
|
||||
}
|
||||
|
||||
// applyOperatorProfileSnapshot rebuilds the editable field list from an operator_profile.snapshot.
|
||||
func (m *Model) applyOperatorProfileSnapshot(about, approvalMode string, preferredModels, conventions []string, proposed, errMsg string) {
|
||||
m.operatorProfileLoading = false
|
||||
m.operatorProfileError = errMsg
|
||||
m.operatorProfileProposed = proposed
|
||||
if errMsg != "" {
|
||||
return
|
||||
}
|
||||
m.operatorProfileFields = []ProfileField{
|
||||
{Key: operatorProfileFieldAbout, Value: about},
|
||||
{Key: operatorProfileFieldApprovalMode, Value: approvalMode},
|
||||
{Key: operatorProfileFieldPreferredModels, Value: strings.Join(preferredModels, ", ")},
|
||||
{Key: operatorProfileFieldConventions, Value: strings.Join(conventions, "; ")},
|
||||
}
|
||||
m.operatorProfileStaged = map[string]string{}
|
||||
}
|
||||
|
||||
// handleOperatorProfileKey owns every key while the operator profile overlay is open.
|
||||
func (m Model) handleOperatorProfileKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.operatorProfileEditing {
|
||||
switch k.Type {
|
||||
case keyEsc:
|
||||
m.operatorProfileEditing = false
|
||||
m.operatorProfileEditBuf = ""
|
||||
case keyEnter:
|
||||
if m.operatorProfileIndex >= 0 && m.operatorProfileIndex < len(m.operatorProfileFields) {
|
||||
m.operatorProfileStaged[m.operatorProfileFields[m.operatorProfileIndex].Key] = m.operatorProfileEditBuf
|
||||
}
|
||||
m.operatorProfileEditing = false
|
||||
m.operatorProfileEditBuf = ""
|
||||
case keyBackspace:
|
||||
if n := len(m.operatorProfileEditBuf); n > 0 {
|
||||
m.operatorProfileEditBuf = m.operatorProfileEditBuf[:n-1]
|
||||
}
|
||||
case keyRunes, keySpace:
|
||||
m.operatorProfileEditBuf += string(k.Runes)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case k.Type == keyEsc || runeIs(k, "o"):
|
||||
m.overlay = OverlayNone
|
||||
case k.Type == keyUp || runeIs(k, "k"):
|
||||
if m.operatorProfileIndex > 0 {
|
||||
m.operatorProfileIndex--
|
||||
}
|
||||
case k.Type == keyDown || runeIs(k, "j"):
|
||||
if m.operatorProfileIndex < len(m.operatorProfileFields)-1 {
|
||||
m.operatorProfileIndex++
|
||||
}
|
||||
case k.Type == keyEnter || k.Type == keySpace:
|
||||
if m.operatorProfileIndex >= 0 && m.operatorProfileIndex < len(m.operatorProfileFields) {
|
||||
m.operatorProfileEditing = true
|
||||
m.operatorProfileEditBuf = m.currentOperatorProfileValue(m.operatorProfileFields[m.operatorProfileIndex])
|
||||
}
|
||||
case runeIs(k, "s"):
|
||||
if len(m.operatorProfileStaged) > 0 {
|
||||
m = m.saveOperatorProfile()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// saveOperatorProfile merges staged edits into the field list and sends the whole profile back.
|
||||
func (m Model) saveOperatorProfile() Model {
|
||||
var about, approvalMode string
|
||||
var preferredModels, conventions []string
|
||||
for _, f := range m.operatorProfileFields {
|
||||
v := m.currentOperatorProfileValue(f)
|
||||
switch f.Key {
|
||||
case operatorProfileFieldAbout:
|
||||
about = v
|
||||
case operatorProfileFieldApprovalMode:
|
||||
approvalMode = v
|
||||
case operatorProfileFieldPreferredModels:
|
||||
preferredModels = splitTrimmed(v, ",")
|
||||
case operatorProfileFieldConventions:
|
||||
conventions = splitTrimmed(v, ";")
|
||||
}
|
||||
}
|
||||
m.client.Send(protocol.UpdateOperatorProfile(about, approvalMode, preferredModels, conventions))
|
||||
return m
|
||||
}
|
||||
|
||||
func splitTrimmed(s, sep string) []string {
|
||||
out := []string{}
|
||||
for _, p := range strings.Split(s, sep) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// currentOperatorProfileValue returns the staged edit for a field if present, else its server value.
|
||||
func (m Model) currentOperatorProfileValue(f ProfileField) string {
|
||||
if v, ok := m.operatorProfileStaged[f.Key]; ok {
|
||||
return v
|
||||
}
|
||||
return f.Value
|
||||
}
|
||||
|
||||
func (m Model) operatorProfileModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("operator profile"))
|
||||
if n := len(m.operatorProfileStaged); n > 0 {
|
||||
b.WriteString(mbg(t, " ("+itoa(n)+" unsaved)", t.P.Warn))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.operatorProfileLoading && len(m.operatorProfileFields) == 0 {
|
||||
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
if m.operatorProfileError != "" {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.operatorProfileError, w-8)) + "\n\n")
|
||||
}
|
||||
|
||||
for i, f := range m.operatorProfileFields {
|
||||
b.WriteString(m.operatorProfileRow(i, f) + "\n")
|
||||
}
|
||||
|
||||
if m.operatorProfileProposed != "" {
|
||||
b.WriteString("\n" + mbg(t, " proposed adaptation (review + merge by hand):", t.P.Warn) + "\n")
|
||||
for _, ln := range wrapLine(m.operatorProfileProposed, w-6) {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(" "+ln) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{
|
||||
{"↑↓", "select"}, {"enter", "edit"}, {"s", "save"}, {"o/esc", "close"},
|
||||
}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) operatorProfileRow(i int, f ProfileField) string {
|
||||
t := m.theme
|
||||
_, staged := m.operatorProfileStaged[f.Key]
|
||||
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
keyFg := t.P.Fg
|
||||
if i == m.operatorProfileIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||
keyFg = t.P.FgStrong
|
||||
}
|
||||
|
||||
var valStr string
|
||||
if m.operatorProfileEditing && i == m.operatorProfileIndex {
|
||||
caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
|
||||
valStr = lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(m.operatorProfileEditBuf) + caret
|
||||
} else {
|
||||
valFg := t.P.Accent2
|
||||
if staged {
|
||||
valFg = t.P.Warn
|
||||
}
|
||||
val := m.currentOperatorProfileValue(f)
|
||||
if staged {
|
||||
val = "*" + val
|
||||
}
|
||||
valStr = lipgloss.NewStyle().Foreground(valFg).Background(t.P.BgPanel).Render(val)
|
||||
}
|
||||
|
||||
return marker + lipgloss.NewStyle().Foreground(keyFg).Background(t.P.BgPanel).Render(padRaw(f.Key, 20)) + " " + valStr
|
||||
}
|
||||
@@ -119,6 +119,10 @@ func (m Model) renderOverlay(base string) string {
|
||||
return m.center(m.artifactsModal())
|
||||
case OverlayConfig:
|
||||
return m.center(m.configModal())
|
||||
case OverlayProjectProfile:
|
||||
return m.center(m.projectProfileModal())
|
||||
case OverlayOperatorProfile:
|
||||
return m.center(m.operatorProfileModal())
|
||||
case OverlayStats:
|
||||
return m.center(m.statsModal())
|
||||
case OverlayIdeas:
|
||||
@@ -160,7 +164,11 @@ func (m Model) approvalBandHeight() int {
|
||||
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
|
||||
rows = m.approvalContentRows(s.Pending)
|
||||
if n := len(s.Pending.Rationale); n > 0 {
|
||||
rat = n + 1 // rationale lines + trailing blank
|
||||
textW := m.width - 4 - 2 // box borders/padding + rationale marker
|
||||
for _, r := range s.Pending.Rationale {
|
||||
rat += len(wrapLine(r, textW))
|
||||
}
|
||||
rat++ // trailing blank
|
||||
}
|
||||
}
|
||||
steer := 0
|
||||
@@ -219,7 +227,10 @@ func (m Model) renderApprovalBand(h int) string {
|
||||
innerH := h - 2
|
||||
ratBlock := 0
|
||||
if len(a.Rationale) > 0 {
|
||||
ratBlock = len(a.Rationale) + 1 // rationale lines + trailing blank
|
||||
for _, r := range a.Rationale {
|
||||
ratBlock += len(wrapLine(r, textW-2))
|
||||
}
|
||||
ratBlock++ // trailing blank
|
||||
}
|
||||
steerBlock := 0
|
||||
if m.steering || m.steerBuffer != "" {
|
||||
@@ -233,7 +244,13 @@ func (m Model) renderApprovalBand(h int) string {
|
||||
body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "")
|
||||
for _, r := range a.Rationale {
|
||||
marker := lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render("▲ ")
|
||||
body = append(body, marker+t.span(truncate(r, textW-2), t.P.Dim))
|
||||
for i, ln := range wrapLine(r, textW-2) {
|
||||
prefix := marker
|
||||
if i > 0 {
|
||||
prefix = " "
|
||||
}
|
||||
body = append(body, prefix+t.span(ln, t.P.Dim))
|
||||
}
|
||||
}
|
||||
if len(a.Rationale) > 0 {
|
||||
body = append(body, "")
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/correx/tui-go/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
projectProfileFieldAbout = "about"
|
||||
projectProfileFieldConventions = "conventions"
|
||||
)
|
||||
|
||||
// openProjectProfile opens the project profile editor for the selected session's bound workspace.
|
||||
func (m *Model) openProjectProfile() {
|
||||
s := m.session(m.selectedID)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
m.overlay = OverlayProjectProfile
|
||||
m.projectProfileIndex = 0
|
||||
m.projectProfileEditing = false
|
||||
m.projectProfileEditBuf = ""
|
||||
m.projectProfileError = ""
|
||||
m.projectProfileStaged = map[string]string{}
|
||||
m.projectProfileLoading = true
|
||||
m.client.Send(protocol.GetProjectProfile(s.ID))
|
||||
}
|
||||
|
||||
// applyProjectProfileSnapshot rebuilds the editable field list from a project_profile.snapshot.
|
||||
func (m *Model) applyProjectProfileSnapshot(about string, conventions []string, commands map[string]string, errMsg string) {
|
||||
m.projectProfileLoading = false
|
||||
m.projectProfileError = errMsg
|
||||
if errMsg != "" {
|
||||
return
|
||||
}
|
||||
fields := []ProfileField{
|
||||
{Key: projectProfileFieldAbout, Value: about},
|
||||
{Key: projectProfileFieldConventions, Value: strings.Join(conventions, "; ")},
|
||||
}
|
||||
for k, v := range commands {
|
||||
fields = append(fields, ProfileField{Key: k, Value: v})
|
||||
}
|
||||
m.projectProfileFields = fields
|
||||
m.projectProfileStaged = map[string]string{}
|
||||
}
|
||||
|
||||
// handleProjectProfileKey owns every key while the project profile overlay is open.
|
||||
func (m Model) handleProjectProfileKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.projectProfileEditing {
|
||||
switch k.Type {
|
||||
case keyEsc:
|
||||
m.projectProfileEditing = false
|
||||
m.projectProfileEditBuf = ""
|
||||
case keyEnter:
|
||||
if m.projectProfileIndex >= 0 && m.projectProfileIndex < len(m.projectProfileFields) {
|
||||
m.projectProfileStaged[m.projectProfileFields[m.projectProfileIndex].Key] = m.projectProfileEditBuf
|
||||
}
|
||||
m.projectProfileEditing = false
|
||||
m.projectProfileEditBuf = ""
|
||||
case keyBackspace:
|
||||
if n := len(m.projectProfileEditBuf); n > 0 {
|
||||
m.projectProfileEditBuf = m.projectProfileEditBuf[:n-1]
|
||||
}
|
||||
case keyRunes, keySpace:
|
||||
m.projectProfileEditBuf += string(k.Runes)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case k.Type == keyEsc || runeIs(k, "p"):
|
||||
m.overlay = OverlayNone
|
||||
case k.Type == keyUp || runeIs(k, "k"):
|
||||
if m.projectProfileIndex > 0 {
|
||||
m.projectProfileIndex--
|
||||
}
|
||||
case k.Type == keyDown || runeIs(k, "j"):
|
||||
if m.projectProfileIndex < len(m.projectProfileFields)-1 {
|
||||
m.projectProfileIndex++
|
||||
}
|
||||
case k.Type == keyEnter || k.Type == keySpace:
|
||||
if m.projectProfileIndex >= 0 && m.projectProfileIndex < len(m.projectProfileFields) {
|
||||
m.projectProfileEditing = true
|
||||
m.projectProfileEditBuf = m.currentProjectProfileValue(m.projectProfileFields[m.projectProfileIndex])
|
||||
}
|
||||
case runeIs(k, "s"):
|
||||
if len(m.projectProfileStaged) > 0 {
|
||||
m = m.saveProjectProfile()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// saveProjectProfile merges staged edits into the field list and sends the whole profile back.
|
||||
func (m Model) saveProjectProfile() Model {
|
||||
s := m.session(m.selectedID)
|
||||
if s == nil {
|
||||
return m
|
||||
}
|
||||
about := ""
|
||||
conventions := []string{}
|
||||
commands := map[string]string{}
|
||||
for _, f := range m.projectProfileFields {
|
||||
v := m.currentProjectProfileValue(f)
|
||||
switch f.Key {
|
||||
case projectProfileFieldAbout:
|
||||
about = v
|
||||
case projectProfileFieldConventions:
|
||||
for _, c := range strings.Split(v, ";") {
|
||||
c = strings.TrimSpace(c)
|
||||
if c != "" {
|
||||
conventions = append(conventions, c)
|
||||
}
|
||||
}
|
||||
default:
|
||||
if v != "" {
|
||||
commands[f.Key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
m.client.Send(protocol.UpdateProjectProfile(s.ID, about, conventions, commands))
|
||||
return m
|
||||
}
|
||||
|
||||
// currentProjectProfileValue returns the staged edit for a field if present, else its server value.
|
||||
func (m Model) currentProjectProfileValue(f ProfileField) string {
|
||||
if v, ok := m.projectProfileStaged[f.Key]; ok {
|
||||
return v
|
||||
}
|
||||
return f.Value
|
||||
}
|
||||
|
||||
func (m Model) projectProfileModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("project profile"))
|
||||
if n := len(m.projectProfileStaged); n > 0 {
|
||||
b.WriteString(mbg(t, " ("+itoa(n)+" unsaved)", t.P.Warn))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.projectProfileLoading && len(m.projectProfileFields) == 0 {
|
||||
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
if m.projectProfileError != "" {
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.projectProfileError, w-8)) + "\n\n")
|
||||
}
|
||||
|
||||
for i, f := range m.projectProfileFields {
|
||||
b.WriteString(m.projectProfileRow(i, f) + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{
|
||||
{"↑↓", "select"}, {"enter", "edit"}, {"s", "save"}, {"p/esc", "close"},
|
||||
}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) projectProfileRow(i int, f ProfileField) string {
|
||||
t := m.theme
|
||||
_, staged := m.projectProfileStaged[f.Key]
|
||||
|
||||
marker := mbg(t, " ", t.P.BgPanel)
|
||||
keyFg := t.P.Fg
|
||||
if i == m.projectProfileIndex {
|
||||
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
|
||||
keyFg = t.P.FgStrong
|
||||
}
|
||||
|
||||
var valStr string
|
||||
if m.projectProfileEditing && i == m.projectProfileIndex {
|
||||
caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
|
||||
valStr = lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(m.projectProfileEditBuf) + caret
|
||||
} else {
|
||||
valFg := t.P.Accent2
|
||||
if staged {
|
||||
valFg = t.P.Warn
|
||||
}
|
||||
val := m.currentProjectProfileValue(f)
|
||||
if staged {
|
||||
val = "*" + val
|
||||
}
|
||||
valStr = lipgloss.NewStyle().Foreground(valFg).Background(t.P.BgPanel).Render(val)
|
||||
}
|
||||
|
||||
return marker + lipgloss.NewStyle().Foreground(keyFg).Background(t.P.BgPanel).Render(padRaw(f.Key, 20)) + " " + valStr
|
||||
}
|
||||
@@ -151,6 +151,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.CurrentStage = msg.StageID
|
||||
s.Tools = nil
|
||||
s.StageTokensUsed = 0
|
||||
s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
|
||||
}
|
||||
case protocol.TypeStageCompleted:
|
||||
@@ -175,6 +176,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
if msg.Response != "" {
|
||||
s.LastResponse = msg.Response
|
||||
}
|
||||
if msg.TotalTokens != nil {
|
||||
s.StageTokensUsed = *msg.TotalTokens
|
||||
}
|
||||
s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
|
||||
}
|
||||
case protocol.TypeInferenceTimeout:
|
||||
@@ -292,15 +296,36 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
case protocol.TypeStageManifest:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
byStage := map[string][]ManifestTool{}
|
||||
budgetByStage := map[string]int{}
|
||||
for _, st := range msg.Stages {
|
||||
tools := make([]ManifestTool, 0, len(st.Tools))
|
||||
for _, td := range st.Tools {
|
||||
tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier})
|
||||
}
|
||||
byStage[st.StageID] = tools
|
||||
if st.TokenBudget != nil {
|
||||
budgetByStage[st.StageID] = *st.TokenBudget
|
||||
}
|
||||
}
|
||||
s.ToolsByStage = byStage
|
||||
s.TokenBudgetByStage = budgetByStage
|
||||
}
|
||||
case protocol.TypeProjectProfile:
|
||||
errMsg := ""
|
||||
if msg.ConfigError != nil {
|
||||
errMsg = *msg.ConfigError
|
||||
}
|
||||
m.applyProjectProfileSnapshot(msg.About, msg.Conventions, msg.Commands, errMsg)
|
||||
case protocol.TypeOperatorProfile:
|
||||
errMsg := ""
|
||||
if msg.ConfigError != nil {
|
||||
errMsg = *msg.ConfigError
|
||||
}
|
||||
proposed := ""
|
||||
if msg.ProposedAdaptation != nil {
|
||||
proposed = *msg.ProposedAdaptation
|
||||
}
|
||||
m.applyOperatorProfileSnapshot(msg.About, msg.ApprovalMode, msg.PreferredModels, msg.Conventions, proposed, errMsg)
|
||||
case protocol.TypeProviderStatus:
|
||||
m.currentModel = msg.ProviderID
|
||||
if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") {
|
||||
@@ -411,7 +436,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
||||
protocol.TypeIdeaList, protocol.TypeHealthChecks,
|
||||
protocol.TypeFileList, protocol.TypeGrantList:
|
||||
protocol.TypeFileList, protocol.TypeGrantList, protocol.TypeOperatorProfile:
|
||||
return ""
|
||||
default:
|
||||
return msg.SessionID
|
||||
|
||||
@@ -324,6 +324,10 @@ func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||
m.openGrants()
|
||||
case "g":
|
||||
m.openConfig()
|
||||
case "P":
|
||||
m.openProjectProfile()
|
||||
case "O":
|
||||
m.openOperatorProfile()
|
||||
case "m":
|
||||
m.openModelsOverlay()
|
||||
case "w":
|
||||
@@ -658,6 +662,13 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.overlay == OverlayConfig {
|
||||
return m.handleConfigKey(k)
|
||||
}
|
||||
// Project/operator profile overlays own all their keys, same reason as config.
|
||||
if m.overlay == OverlayProjectProfile {
|
||||
return m.handleProjectProfileKey(k)
|
||||
}
|
||||
if m.overlay == OverlayOperatorProfile {
|
||||
return m.handleOperatorProfileKey(k)
|
||||
}
|
||||
// Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't
|
||||
// share the generic no-cmd esc path below.
|
||||
if m.overlay == OverlaySessions {
|
||||
|
||||
@@ -182,7 +182,11 @@ func (m Model) renderStatus() string {
|
||||
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
|
||||
parts = append(parts, span(s.Name, t.P.FgStrong))
|
||||
if s.CurrentStage != "" && m.sbShow("stage") {
|
||||
parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2))
|
||||
stageLabel := "⟐ " + s.CurrentStage
|
||||
if budget, ok := s.TokenBudgetByStage[s.CurrentStage]; ok && budget > 0 {
|
||||
stageLabel += fmt.Sprintf(" (%d/%d)", s.StageTokensUsed, budget)
|
||||
}
|
||||
parts = append(parts, span(stageLabel, t.P.Accent2))
|
||||
}
|
||||
if s.Status != "" && m.sbShow("status") {
|
||||
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
|
||||
|
||||
@@ -53,6 +53,8 @@ const (
|
||||
TypeRouterNarration = "router.narration"
|
||||
TypeArtifactList = "artifact.list"
|
||||
TypeConfigSnapshot = "config.snapshot"
|
||||
TypeProjectProfile = "project_profile.snapshot"
|
||||
TypeOperatorProfile = "operator_profile.snapshot"
|
||||
TypeSessionStats = "session.stats"
|
||||
TypeIdeaList = "idea.list"
|
||||
TypeHealthChecks = "health.checks"
|
||||
@@ -139,6 +141,15 @@ type ServerMessage struct {
|
||||
ConfigRestartRequired []string `json:"restartRequired"`
|
||||
ConfigError *string `json:"error"`
|
||||
|
||||
// project_profile.snapshot / operator_profile.snapshot (About/Conventions shared; Commands is
|
||||
// project-only, ApprovalMode/PreferredModels/ProposedAdaptation are operator-only)
|
||||
About string `json:"about"`
|
||||
Conventions []string `json:"conventions"`
|
||||
Commands map[string]string `json:"commands"`
|
||||
ApprovalMode string `json:"approvalMode"`
|
||||
PreferredModels []string `json:"preferredModels"`
|
||||
ProposedAdaptation *string `json:"proposedAdaptation"`
|
||||
|
||||
// session.stats — derived metrics for a session (reply to GetSessionStats)
|
||||
Stats *StatsDto `json:"stats"`
|
||||
|
||||
@@ -334,8 +345,9 @@ type ApprovalDto struct {
|
||||
}
|
||||
|
||||
type StageToolDecl struct {
|
||||
StageID string `json:"stageId"`
|
||||
Tools []ToolDecl `json:"tools"`
|
||||
StageID string `json:"stageId"`
|
||||
Tools []ToolDecl `json:"tools"`
|
||||
TokenBudget *int `json:"tokenBudget"`
|
||||
}
|
||||
|
||||
type ToolDecl struct {
|
||||
@@ -460,6 +472,38 @@ func UpdateConfig(patch map[string]string) []byte {
|
||||
return encode("UpdateConfig", map[string]any{"patch": patch})
|
||||
}
|
||||
|
||||
// GetProjectProfile requests the ProjectProfile bound to a session's workspace (replied to with
|
||||
// a project_profile.snapshot).
|
||||
func GetProjectProfile(sessionID string) []byte {
|
||||
return encode("GetProjectProfile", map[string]any{"sessionId": sessionID})
|
||||
}
|
||||
|
||||
// UpdateProjectProfile overwrites the ProjectProfile at a session's workspace root.
|
||||
func UpdateProjectProfile(sessionID, about string, conventions []string, commands map[string]string) []byte {
|
||||
return encode("UpdateProjectProfile", map[string]any{
|
||||
"sessionId": sessionID,
|
||||
"about": about,
|
||||
"conventions": conventions,
|
||||
"commands": commands,
|
||||
})
|
||||
}
|
||||
|
||||
// GetOperatorProfile requests the operator's OperatorProfile (replied to with an
|
||||
// operator_profile.snapshot). Not session-scoped.
|
||||
func GetOperatorProfile() []byte {
|
||||
return encode("GetOperatorProfile", map[string]any{})
|
||||
}
|
||||
|
||||
// UpdateOperatorProfile overwrites the OperatorProfile.
|
||||
func UpdateOperatorProfile(about, approvalMode string, preferredModels, conventions []string) []byte {
|
||||
return encode("UpdateOperatorProfile", map[string]any{
|
||||
"about": about,
|
||||
"approvalMode": approvalMode,
|
||||
"preferredModels": preferredModels,
|
||||
"conventions": conventions,
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalResponse answers an approval gate. decision is "APPROVE" or "REJECT".
|
||||
func ApprovalResponse(requestID, decision string, steeringNote *string) []byte {
|
||||
return encode("ApprovalResponse", map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user